Spring Boot
Method Level Security
Method level security enforces authorization on service methods with annotations, independent of how the method is reached (HTTP, a scheduled job, a message listener). URL rules answer 'can this role reach this endpoint'; method security answers 'can this user do this to this object'. This entry covers enabling it, @PreAuthorize, @PostAuthorize, @PreFilter/@PostFilter, @Secured and @RolesAllowed, meta-annotations, domain-object permissions, exception handling, testing, and the pitfalls that silently disable or weaken checks. It targets Spring Security 6.x (Spring Boot 3.x).
Enabling Method Security and How It Works
Method security is off by default. Add @EnableMethodSecurity to a @Configuration class. In Spring Security 6 it replaces @EnableGlobalMethodSecurity (deprecated, and removed in Security 7). Pre/post annotations (@PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter) are on by default; @Secured and JSR-250 annotations must be switched on explicitly. Under the hood Spring wraps the bean in a proxy, and each call passes through an AuthorizationManager-based interceptor that either lets the call through or throws AccessDeniedException. That means checks only apply to calls that cross the proxy: self-invocation (this.method()) and calls on objects created with new are never checked. Annotations can sit on the class (applied to every method, overridable per method) or on individual public methods. Treat method security as a second layer behind your URL rules, not a replacement for them.
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(
securedEnabled = true, // @Secured (default false)
jsr250Enabled = true // @RolesAllowed, @PermitAll, @DenyAll (default false)
) // prePostEnabled defaults to true
public class SecurityConfig {
// SecurityFilterChain bean ...
}
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAllOrders() { ... }
public void processOrders() {
// BYPASSES the check: 'this' is the raw bean, not the proxy
List<Order> orders = this.findAllOrders();
}
}
// Fix: move the secured method to another bean and inject it,
// or decide that the internal caller is trusted and skip the annotation there.@PreAuthorize
@PreAuthorize evaluates a SpEL expression before the method runs. If it is false the method body never executes, and by default the security interceptor runs before @Transactional, so a denied call does not even open a transaction. This is the annotation to use for almost everything. Remember the prefix rule: hasRole('ADMIN') checks for the authority ROLE_ADMIN, while hasAuthority('user:read') checks the string exactly. Expressions can reference parameters with #name, the current user with authentication or principal, and Spring beans with @beanName. Parameter names are only available if the code is compiled with -parameters (Spring Boot's Maven and Gradle plugins do this) or the parameter is annotated with @P. Keep expressions short. Anything longer than one condition belongs in a named bean method that you can unit test.
Java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> findAll() { ... }
@PreAuthorize("hasAuthority('user:read')")
public UserResponse findById(Long id) { ... }
// Owner or admin. 'principal.id' needs a custom UserDetails that exposes getId();
// the default User class has no id property.
@PreAuthorize("hasRole('ADMIN') or #id == principal.id")
public UserResponse findOwnProfileOrAny(Long id) { ... }
// Fields of a request object
@PreAuthorize("hasRole('ADMIN') or #request.email == authentication.name")
public UserResponse update(Long id, UpdateUserRequest request) { ... }
// Delegate non-trivial rules to a bean that is easy to test
@PreAuthorize("@userAccess.canEdit(authentication, #id)")
public UserResponse edit(Long id, EditUserRequest request) { ... }
@PreAuthorize("isAuthenticated()")
public UserResponse currentUser() {
// read the user from SecurityContextHolder inside the method
}
}
@Component("userAccess")
public class UserAccess {
public boolean canEdit(Authentication auth, Long targetId) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))
|| ((AppUser) auth.getPrincipal()).getId().equals(targetId);
}
}@PostAuthorize, @PreFilter and @PostFilter
@PostAuthorize runs after the method returns and can inspect returnObject. The important caveat: the method has already executed, so any side effect (a write, an event, an email) has already happened, and with the default interceptor ordering the transaction has typically already committed. Use it only for reads, such as checking the owner of a row you cannot know before loading it. @PostFilter and @PreFilter remove elements from a collection after or before the call using filterObject. They work in memory on the whole collection, so @PostFilter on findAll() loads every row and then discards most of them. For large tables filter in the query instead. Both filters mutate the collection in place, so an immutable List.of(...) throws UnsupportedOperationException. With more than one collection parameter, @PreFilter needs filterTarget.
Java
@Service
@RequiredArgsConstructor
public class DocumentService {
private final DocumentRepository documentRepository;
// Read-only check on the loaded object
@PostAuthorize("returnObject.ownerUsername == authentication.name or hasRole('ADMIN')")
public Document findById(Long id) {
return documentRepository.findById(id).orElseThrow();
}
// Fine for small, bounded result sets only
@PostFilter("filterObject.visibility == 'PUBLIC' or filterObject.ownerUsername == authentication.name")
public List<Document> findByTag(String tag) {
return documentRepository.findByTag(tag); // must return a mutable collection
}
// Better for large data: let the database do the filtering
@PreAuthorize("isAuthenticated()")
public Page<Document> findVisible(Pageable pageable) {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
return documentRepository.findVisibleTo(username, pageable);
}
@PreFilter(value = "filterObject.ownerUsername == authentication.name",
filterTarget = "documents") // required with more than one collection parameter
public void archive(List<Document> documents, List<Long> auditIds) { ... }
}@Secured and @RolesAllowed
@Secured and the JSR-250 annotations take plain strings and no SpEL, so they cannot look at parameters, return values, or beans. @Secured takes the full authority name and adds no prefix, so you write @Secured("ROLE_ADMIN"). It can also match non-role authorities such as @Secured("user:read"). @RolesAllowed is the JSR-250 standard; it adds the ROLE_ prefix for you, so write @RolesAllowed("ADMIN"). Both require the matching flag on @EnableMethodSecurity. In a new Spring codebase there is little reason to use them: @PreAuthorize does the same thing and grows with your requirements, whereas mixing three annotation families makes it harder to audit who can call what. They make sense when you must stay portable with Jakarta EE code or want to forbid expressions in a module.
Java
@Service
public class AdminService {
@Secured("ROLE_ADMIN") // prefix is part of the string
public void purgeCache() { ... }
@Secured({"ROLE_ADMIN", "ROLE_MODERATOR"}) // any of
public void moderate() { ... }
@RolesAllowed("ADMIN") // ROLE_ added automatically
public void jsr250AdminAction() { ... }
@PermitAll
public void publicAction() { ... }
@DenyAll
public void disabled() { ... }
}
// These pairs behave the same:
// @Secured("ROLE_ADMIN") == @PreAuthorize("hasRole('ADMIN')")
// @Secured("user:read") == @PreAuthorize("hasAuthority('user:read')")Meta-Annotations for Reusable Rules
Repeating the same SpEL string across dozens of methods is a typo waiting to happen, and a typo in a SpEL string fails at runtime, not compile time. Wrap the rule in a custom annotation that is itself annotated with @PreAuthorize. Spring Security detects it on methods and classes. This also gives the rule a name that reads well in code review.
Java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin { }
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated() and @userAccess.canEdit(authentication, #id)")
public @interface CanEditUser { }
@Service
public class ReportService {
@IsAdmin
public Report generateAuditReport() { ... }
@CanEditUser
public void resetPassword(Long id) { ... }
}Domain Object Permissions
When the decision depends on a specific object, you have two options. The simplest is a bean method called from SpEL (shown earlier). The more formal option is a PermissionEvaluator, which powers hasPermission(object, 'READ') and hasPermission(id, 'Document', 'READ') and gives you one central place for the policy. Register it through a MethodSecurityExpressionHandler declared as a static @Bean method, otherwise Spring creates it too early and logs that the configuration class is not eligible for post-processing. Injecting the evaluator with @Lazy avoids pulling repositories into that early phase. Be aware that the id-based form loads the entity for the check and then your method loads it again, so for hot paths prefer the object-based form or cache within the request.
Java
@Component
@RequiredArgsConstructor
public class DocumentPermissionEvaluator implements PermissionEvaluator {
private final DocumentRepository documents;
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
return target instanceof Document doc && check(auth, doc, permission.toString());
}
@Override
public boolean hasPermission(Authentication auth, Serializable id,
String type, Object permission) {
if (!"Document".equals(type)) return false;
return documents.findById((Long) id)
.map(d -> check(auth, d, permission.toString()))
.orElse(false); // missing row reads as 403, not 404
}
private boolean check(Authentication auth, Document doc, String permission) {
boolean owner = doc.getOwnerUsername().equals(auth.getName());
boolean admin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
return switch (permission) {
case "READ" -> doc.isPublic() || owner || admin;
case "WRITE", "DELETE" -> owner || admin;
default -> false;
};
}
}
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
@Lazy DocumentPermissionEvaluator evaluator) {
var handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(evaluator);
return handler;
}
}
@Service
public class DocumentService {
@PreAuthorize("hasPermission(#doc, 'WRITE')")
public Document save(Document doc) { ... }
@PreAuthorize("hasPermission(#id, 'Document', 'DELETE')")
public void delete(Long id) { ... }
}Handling AccessDeniedException
A failed check throws AccessDeniedException (or AuthorizationDeniedException, its subclass in Security 6.3+). When the call came through an HTTP request, the exception propagates to Spring Security's ExceptionTranslationFilter, which returns 403 for an authenticated user and triggers the entry point (usually 401) for an anonymous one. The common trap is a catch-all @ExceptionHandler(Exception.class) in a @ControllerAdvice: it catches the exception before the filter sees it and turns every denied call into a 500 or a misleading 200 error body. Either rethrow security exceptions or handle them explicitly.
Java
@RestControllerAdvice
public class GlobalExceptionHandler {
// Explicit mapping, so the catch-all below never swallows it
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ProblemDetail> handleDenied(AccessDeniedException ex) {
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, "You do not have permission to perform this action");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleAny(Exception ex) throws Exception {
if (ex instanceof AccessDeniedException || ex instanceof AuthenticationException) {
throw ex; // let Spring Security's filters respond
}
return ResponseEntity.internalServerError()
.body(ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR));
}
}Testing Method Security
Test security rules where they live, on the service, and keep a few MockMvc tests to prove the HTTP mapping. Add spring-security-test, then use @WithMockUser to place an Authentication in the SecurityContext for one test. Cover three cases for every rule: allowed, denied, and anonymous. Anonymous callers get AuthenticationCredentialsNotFoundException, not AccessDeniedException, because there is no Authentication at all. A plain @ExtendWith(MockitoExtension.class) test never creates a proxy, so annotations are ignored there. Either load a minimal Spring context with @EnableMethodSecurity, as below, or use @SpringBootTest. In a @WebMvcTest slice, services are mocked, so you are testing controller-level rules only.
Java
// pom.xml
// <dependency>
// <groupId>org.springframework.security</groupId>
// <artifactId>spring-security-test</artifactId>
// <scope>test</scope>
// </dependency>
@SpringJUnitConfig
class UserServiceSecurityTest {
@Configuration
@EnableMethodSecurity
@Import(UserService.class)
static class Config {
@Bean UserRepository userRepository() { return mock(UserRepository.class); }
}
@Autowired UserService userService;
@Test
@WithMockUser(roles = "ADMIN")
void findAll_allowedForAdmin() {
assertThatNoException().isThrownBy(() -> userService.findAll());
}
@Test
@WithMockUser(roles = "USER")
void findAll_deniedForUser() {
assertThatThrownBy(() -> userService.findAll())
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithMockUser(authorities = "user:read") // authority, no ROLE_ prefix
void findById_allowedWithAuthority() {
assertThatNoException().isThrownBy(() -> userService.findById(1L));
}
@Test
void findAll_deniedWhenAnonymous() {
assertThatThrownBy(() -> userService.findAll())
.isInstanceOf(AuthenticationCredentialsNotFoundException.class);
}
}
@WebMvcTest(UserController.class)
class UserControllerSecurityTest {
@Autowired MockMvc mockMvc;
@MockitoBean UserService userService;
@Test
void getUsers_returns403WhenServiceDenies() throws Exception {
when(userService.findAll()).thenThrow(new AccessDeniedException("denied"));
mockMvc.perform(get("/api/users").with(user("bob").roles("USER")))
.andExpect(status().isForbidden());
}
}Common Pitfalls
Most method security bugs fail open, meaning the check silently does not run. Self-invocation and objects created with new bypass the proxy. Annotations on private methods, or on final classes or methods under certain proxy setups, have no effect. Forgetting @EnableMethodSecurity (or the securedEnabled/jsr250Enabled flag for those annotations) makes every annotation a comment. Using hasRole('ROLE_ADMIN') looks for ROLE_ROLE_ADMIN. Referencing #id without -parameters or @P evaluates to null and the rule quietly denies or, worse, passes. @PostAuthorize and @PostFilter run after side effects and after the data was loaded. A catch-all exception handler turns 403 into 500. Relying on the UI or on URL rules alone leaves the service callable from jobs, listeners, and other services. When adding a rule, always write the denial test first and watch it fail.
Java
// 1. Wrong prefix
@PreAuthorize("hasRole('ROLE_ADMIN')") // BAD: checks ROLE_ROLE_ADMIN
@PreAuthorize("hasRole('ADMIN')") // GOOD
// 2. Parameter name unavailable without -parameters
@PreAuthorize("#id == principal.id") // may resolve #id to null
@PreAuthorize("#userId == principal.id")
public void update(@P("userId") Long id) { ... }
// 3. Write operation guarded after the fact
@PostAuthorize("returnObject.owner == authentication.name")
public Document rename(Long id, String name) { ... } // BAD: already saved
@PreAuthorize("hasPermission(#id, 'Document', 'WRITE')")
public Document rename(Long id, String name) { ... } // GOOD: checked first
// 4. Private methods are not intercepted
@PreAuthorize("hasRole('ADMIN')")
private void internalCleanup() { ... } // annotation ignored