Spring Security Method-Level Security Annotations
Secure Spring Boot methods with @PreAuthorize, @Secured, @RolesAllowed annotations and SpEL expressions for fine-grained access control.
Secure Spring Boot methods with @PreAuthorize, @Secured, @RolesAllowed annotations and SpEL expressions for fine-grained access control. The guide uses practical examples to explain introduction: why method-level security matters, getting started: enabling method security and shows how to apply the ideas in a Spring Boot project. It closes with common pitfalls and production checks so you can apply the pattern with fewer surprises.
Spring Security Method-Level Security Annotations
Method-level security fills the gaps that URL-based security cannot reach. URL security controls which endpoints a user can hit. Method-level annotations decide whether they can actually run a given operation once they are inside. Think building key card versus the specific vault key inside.
We will walk through the four main method-security annotations, when to use each, how SpEL expressions give you fine-grained control, and the failure modes that trip up even experienced developers.
Introduction: Why Method-Level Security Matters
Introduction
Method-level security enforces authorization at the service or operation boundary, where the application knows which resource is being changed and why. This guide compares @PreAuthorize, @PostAuthorize, @Secured, and @RolesAllowed, demonstrates SpEL-based checks, and explains proxy, testing, and failure behavior.
Getting Started: Enabling Method Security
Before using any annotation, you must enable method-level security explicitly in your configuration class.
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// ...
}
The @EnableMethodSecurity annotation registers the necessary AOP infrastructure. Without it, the annotations have no effect.
For Spring Security 6.x and Spring Boot 3.x, this single annotation replaces the older @EnableGlobalMethodSecurity(prePostEnabled = true) style configuration.
@Secured: Simple Role-Based Checks
The @Secured annotation provides the simplest path into method-level security. It accepts an array of role names, and the method executes only if the authenticated principal possesses at least one of those roles.
@Service
public class UserManagementService {
@Secured("ROLE_ADMIN")
public User createUser(User user) {
// Only principals with ROLE_ADMIN can reach here
return userRepository.save(user);
}
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public List<User> listUsers() {
return userRepository.findAll();
}
}
The ROLE_ Prefix Convention
@Secured expects role names with the ROLE_ prefix already included. When Spring Security checks your authentication, it looks for authorities that exactly match the strings you provide. If your UserDetails returns authorities like ROLE_ADMIN, then @Secured(“ROLE_ADMIN”) works. If it returns just ADMIN, the check fails silently and access is denied.
This trips people up regularly. Check what your UserDetails actually returns.
Limitations of @Secured
@Secured is deliberately minimal. It supports only role checks, no SpEL, no complex expressions, no parameter inspection. When your authorization logic outgrows simple role membership, you will need to reach for @PreAuthorize.
@RolesAllowed: The JSR-250 Standard Equivalent
@RolesAllowed is the Jakarta EE standard annotation for method authorization. Spring Security supports it through the spring-security-config module, and it behaves almost identically to @Secured.
@Service
public class PaymentService {
@RolesAllowed("ROLE_FINANCE")
public void processPayment(Payment payment) {
// Only ROLE_FINANCE principals enter here
}
@RolesAllowed({"ROLE_FINANCE", "ROLE_ACCOUNTING"})
public FinancialReport generateReport() {
return reportRepository.generate();
}
}
The @RolesAllowed Rationale
So why does Spring give you both? Standards compliance. @Secured is Spring-specific. @RolesAllowed comes from JSR-250, the Java EE security spec. If you need your code to run in other Jakarta EE containers without changes, @RolesAllowed keeps you aligned with the standard.
Most Spring Boot projects just use @Secured because it is more familiar. The choice between them is mostly stylistic.
@PreAuthorize: Expression-Based Access Control
@PreAuthorize is where things get interesting. Instead of a static list of role names, you write a SpEL expression that Spring evaluates at runtime.
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')")
public Document deleteDocument(Long id) {
return documentRepository.deleteById(id);
}
@PreAuthorize("hasRole('EDITOR') or hasRole('ADMIN')")
public Document updateDocument(Long id, Document doc) {
return documentRepository.save(doc);
}
@PreAuthorize("hasAuthority('WRITE_DOCUMENTS')")
public Document createDocument(Document doc) {
return documentRepository.save(doc);
}
}
hasRole() vs hasAuthority()
Both check permissions on the current authentication object, but handle prefixes differently:
hasRole(‘ADMIN’)prependsROLE_internally and checks forROLE_ADMINhasAuthority(‘ADMIN’)checks for an exact match onADMIN
Use hasAuthority() when your UserDetails returns fine-grained permissions like READDOCUMENTS or WRITE_DOCUMENTS. Use hasRole() when you are working with coarse role names that consistently carry the ROLE prefix.
Deny-All and Permit-All
@PreAuthorize("denyAll")
public void unreachableMethod() {
// No principal can reach here
}
@PreAuthorize("permitAll")
public void publicMethod() {
// Always accessible, regardless of authentication
}
Handy for marking methods that should never be invoked or that handle auth internally.
SpEL in Security: Beyond Simple Role Checks
SpEL expressions in @PreAuthorize handle far more than role names. You get method parameters, nested property access, custom bean method calls, and boolean logic that can get as complex as you need.
Accessing Method Parameters
Prefix any method parameter with # to reference it in your expression.
@Service
public class AccountService {
@PreAuthorize("#ownerId == authentication.principal.id or hasRole('ADMIN')")
public Account getAccount(Long ownerId) {
return accountRepository.findById(ownerId);
}
@PreAuthorize("#amount <= 10000 or hasRole('ADMIN')")
public TransferResult transfer(Long fromAccount, Long toAccount, BigDecimal amount) {
// ...
}
}
The first example checks ownership: a user sees their own account, admins see anything. The second ties a transaction limit to the caller’s role.
Calling Custom Expression Methods
For reusable logic, write a Spring bean method and reference it from your expression.
@Component
public class AuthorizationExpressions {
public boolean isAccountOwner(Authentication auth, Long ownerId) {
UserDetails user = (UserDetails) auth.getPrincipal();
return user.getUsername().equals(ownerRepository.findById(ownerId).getOwnerEmail());
}
public boolean canAccessDocument(Authentication auth, Document doc) {
UserDetails user = (UserDetails) auth.getPrincipal();
return doc.getVisibility() == Visibility.PUBLIC
|| doc.getOwner().equals(user.getUsername())
|| user.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
}
@Service
public class DocumentService {
@PreAuthorize("@authzExpressions.isAccountOwner(authentication, #ownerId)")
public Document getOwnerDocument(Long ownerId) {
// ...
}
@PreAuthorize("@authzExpressions.canAccessDocument(authentication, #doc)")
public void updateDocument(Document doc) {
// ...
}
}
The @ prefix references the bean name, then the method name and parentheses.
Complex Boolean Logic
@PreAuthorize(
"(hasRole('USER') and #resource.owner == authentication.principal.username) " +
"or hasAnyRole('ADMIN', 'MODERATOR')"
)
public void modifyResource(Resource resource) {
// ...
}
Accessing the Authentication Object Directly
Within an expression, authentication binds to the current Authentication object, and principal binds to its principal object (often a UserDetails instance).
@PreAuthorize("authentication.name == 'system'")
public void runSystemTask() {
// Only the system account can invoke this
}
Mermaid Diagram: The Security Filter Chain and AOP Proxy
The following diagram shows how a secured method call flows through the Spring Security infrastructure:
graph TD
A[HTTP Request] --> B[Security Filter Chain]
B --> C{Authentication Filter}
C -->|Authenticated| D[Controller / Service Bean]
D --> E["AOP Proxy"]
E --> F[Authorization Interceptor]
F --> G["@PreAuthorize / @Secured check"]
G -->|Access Denied| H[AccessDeniedException → 403]
G -->|Access Granted| I[Target Method]
I --> J[Response]
H --> J
Spring wraps your bean in a proxy at runtime. External calls hit this proxy first, which evaluates the annotation before handing off to the real method.
When to Use Each Annotation
| Annotation | Best For | Limitations |
|---|---|---|
@Secured |
Simple role checks, legacy compatibility | No SpEL, requires ROLE_ prefix |
@RolesAllowed |
JSR-250 compliant code, EE portability | Same limitations as @Secured |
@PreAuthorize |
Complex expressions, parameter inspection | Slightly more overhead |
| URL security | Protecting routes and endpoints | Cannot protect method internals |
Use @PreAuthorize when
- You need to inspect method parameters as part of the authorization decision
- You want to combine multiple conditions with
and,or,not - You need custom authorization logic that does not map cleanly to roles
- You want to call reusable expression methods via
@beanName.methodName()
Use @Secured or @RolesAllowed when
- You only need simple role checks
- You are working with a codebase that standardizes on one annotation
- Performance is critical and the minimal overhead of SpEL parsing matters (rare)
Avoid Method Security when
- Your logic is purely based on URL paths (use
SecurityFilterChaininstead) - The check requires database lookups on every call (consider caching or a different approach)
- You are securing a read-only operation that can be handled by URL-level authentication
Implementation Snippets
@PreAuthorize with hasRole() and SpEL
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
// Additional expression handlers can be registered here
}
@Service
public class OrderService {
@PreAuthorize("hasRole('CUSTOMER') and #customerId == authentication.principal.id")
public Order getCustomerOrder(Long customerId, Long orderId) {
return orderRepository.findById(orderId)
.filter(o -> o.getCustomerId().equals(customerId))
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT')")
public Order getAnyOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}
Custom @PreAuthorize Expression
@Component("authz")
public class AuthorizationFacade {
public boolean canManageEmployee(Authentication auth, Long employeeId) {
UserDetails user = (UserDetails) auth.getPrincipal();
Employee employee = employeeRepository.findById(employeeId).orElse(null);
if (employee == null) {
return false;
}
// Users can manage their own profile; admins can manage anyone
return employee.getEmail().equals(user.getUsername())
|| user.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
}
@Service
public class HrService {
@PreAuthorize("@authz.canManageEmployee(authentication, #employeeId)")
public void updateEmployee(Long employeeId, EmployeeUpdate update) {
// ...
}
}
Method Security with Method Parameters
@Service
public class FileService {
@PreAuthorize(
"#uploaderId == authentication.principal.id " +
"or hasAnyRole('ADMIN', 'CONTENT_MANAGER')"
)
public FileMetadata uploadFile(Long uploaderId, MultipartFile file) {
// Store file and return metadata
}
@PreAuthorize("#ownerId == authentication.principal.id or hasRole('ADMIN')")
public void deleteFile(Long ownerId, String fileId) {
fileStorage.delete(fileId);
}
}
Failure Scenarios
@Async + Method Security: The Security Context Vanishes
When you annotate a method with @Async, Spring executes it in a separate thread pool thread. By default, the SecurityContext from the original request thread does not propagate to the async thread.
@Service
public class NotificationService {
@Async
@PreAuthorize("hasRole('ADMIN')")
public void sendBulkNotification(List<String> userIds, String message) {
// PROBLEM: authentication is null here in the async thread
// AccessDeniedException or worse, the @PreAuthorize is never evaluated
}
}
Fix this by configuring the AsyncTaskExecutor to use a SecurityContext propagator, or by explicitly passing authentication context to the async operation.
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
executor.setSecurityContextProvider(new SecurityContextSimpleAsyncTaskExecutor());
return executor;
}
}
Self-Invocation: When the Proxy Does Not Intercept
Spring AOP uses proxies. External calls go through the proxy and get intercepted. But when a bean calls its own method directly, it bypasses the proxy entirely.
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(Long orderId) {
// This call goes through the proxy
}
public void cancelOrder(Long orderId) {
// This internal call bypasses the proxy
// @PreAuthorize is NOT evaluated
deleteOrder(orderId);
}
}
The cancelOrder method calls deleteOrder directly on this. No proxy intercepts this call, so the @PreAuthorize check never runs.
Solutions include extracting the secured method to a separate bean (so the call crosses bean boundaries and hits the proxy) or using AspectJ weaving instead of Spring AOP proxies.
CSRF with Method Security: Wrong Threat Model
Method security and CSRF protection address different threats. URL security with CSRF tokens prevents cross-site request forgery at the HTTP layer. Method security prevents unauthorized operation execution after authentication.
Do not assume that method-level @PreAuthorize replaces CSRF protection. If your endpoints accept state-changing requests from browsers, you still need CSRF tokens in your HTTP security configuration.
SpEL Injection: When Expressions Come from User Input
Never construct SpEL expressions from untrusted user input.
// DANGEROUS: Never do this
@PreAuthorize("hasRole('" + userProvidedRole + "')")
public void dangerousMethod() {
// An attacker providing "ADMIN') or hasRole('SUPERUSER" escapes the string
// and gains elevated access
}
Spring Security’s StringExpressionParser does not guard against injection in the same way parameterized queries protect against SQL injection. Treat any SpEL in annotations as constants, not as anything derived from user input.
Trade-off Table
| Feature | @Secured | @RolesAllowed | @PreAuthorize |
|---|---|---|---|
| Simple role check | Yes | Yes | Yes |
| SpEL expressions | No | No | Yes |
| Parameter access | No | No | Yes |
| JSR-250 standard | No | Yes | No |
| Complex boolean logic | No | No | Yes |
| Custom bean method calls | No | No | Yes |
| Method post-processing | No | No | @PostAuthorize |
| Performance overhead | Minimal | Minimal | Slight (SpEL parsing) |
| Learning curve | Low | Low | Moderate |
Observability Checklist
Securing methods is only half the job. You also need to know when authorization fails and who tried what.
Enable Access Decision Logging
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public DefaultMethodSecurityExpressionHandler expressionHandler() {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new AuditPermissionEvaluator());
return handler;
}
}
Log Access Denied Events
@Component
public class SecurityAuditListener implements ApplicationListener<AuthorizationEvent> {
private static final Logger log = LoggerFactory.getLogger(SecurityAuditListener.class);
@Override
public void onApplicationEvent(AuthorizationEvent event) {
Authentication auth = event.getAuthentication();
AuthorizationFailureReason reason = event.getAuthorizationFailureReason();
log.warn("Access denied for user '{}': {}",
auth != null ? auth.getName() : "anonymous",
reason);
}
}
Structured Audit Trail
For production systems, emit structured audit records whenever a sensitive method is invoked:
@PreAuthorize("hasRole('ADMIN')")
@Audit(event = "ADMIN_ACTION", captureArgs = true)
public void deleteSystemConfiguration(String configKey) {
// ...
}
Consider using Spring ACD (Annotation-driven Auditing) or a custom aspect to capture who did what and when, without scattering audit logic across every service method.
Security Notes
Method Security vs URL Security
URL security operates at the HTTP layer, before your application code runs. It is fast, declarative, and applies to all requests matching a path pattern. It cannot, however, make decisions based on runtime state like method parameters or database contents.
Method security runs later in the call chain, with full access to the method signature, parameters, and return values. This flexibility comes with more overhead and slightly higher complexity.
Use both. URL security handles the coarse-grained gate. Method security handles the fine-grained checks inside.
Combining with SecurityFilterChain
Method-level annotations work alongside SecurityFilterChain configuration, not in place of it.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
Method annotations then layer additional guards on specific operations within those accessible endpoints.
Pre vs Post Authorization
@PreAuthorize evaluates the expression before the method body runs. @PostAuthorize evaluates it after, with access to the return value.
@PostAuthorize("returnObject.owner == authentication.principal.username or hasRole('ADMIN')")
public Document getDocument(Long id) {
// @PostAuthorize can inspect the returned object
return documentRepository.findById(id);
}
Default to @PreAuthorize. Reach for @PostAuthorize when the authorization decision actually depends on what the method returns.
Common Pitfalls / Anti-Patterns
The ROLE_ Prefix Inconsistency
Spring Security has a historical quirk where role-checking methods like hasRole() automatically prefix ROLE_, but the annotation strings and UserDetails authorities do not automatically align.
@Secured(“ADMIN”)looks forROLE_ADMINauthority@PreAuthorize(“hasRole(‘ADMIN’)”)looks forROLE_ADMINauthority@PreAuthorize(“hasAuthority(‘ADMIN’)”)looks forADMINauthority (no prefix)
Be consistent. If your UserDetails returns ROLE_ADMIN, use hasRole(‘ADMIN’) or @Secured(“ROLE_ADMIN”). If it returns ADMIN, use hasAuthority(‘ADMIN’).
@Async Breaking the Security Context
As covered in the failure scenarios, @Async methods lose the SecurityContext. Configure context propagation explicitly when using @Async with method security.
SpEL Injection Vulnerabilities
Constructing dynamic SpEL from user input creates an injection surface. Keep expressions static. If you need dynamic behavior, call a bean method that encapsulates the logic safely.
Expecting Method Security on Private Methods
Spring AOP proxies can only intercept methods that are externally callable. private methods, constructors, and static methods cannot be secured with these annotations. If you need to secure internal logic, extract it to a separate bean.
Forgetting @EnableMethodSecurity
Without @EnableMethodSecurity, annotations do nothing. This sounds obvious, but it is the most common issue in early setup. If your security annotations seem ignored, double-check that the configuration class has the enabling annotation.
Quick Recap Checklist
- Enable method security with
@EnableMethodSecurityon your configuration class. - Use
@Securedfor simple, static role checks when you do not need SpEL. - Use
@RolesAllowedfor JSR-250 standard compliance in EE environments. - Use
@PreAuthorizefor complex expressions, parameter inspection, and custom logic. - Remember that
hasRole()adds theROLE_prefix;hasAuthority()does not. - Access method parameters in expressions using the
#prefix. - Call custom bean methods from expressions using the
@beanName.method()syntax. - Watch for self-invocation bypassing the AOP proxy.
- Configure
@AsyncwithSecurityContextpropagation if using method security asynchronously. - Never construct SpEL expressions from untrusted user input.
- Use
@PostAuthorizewhen you need to inspect the return value for authorization. - Log authorization failures for audit and debugging.
- Combine URL security and method security for defense in depth.
Interview Questions
@Secured supports only simple role-based checks with a fixed list of role names. It does not support SpEL expressions, method parameters, or complex boolean logic.
@PreAuthorize accepts a SpEL expression that Spring evaluates at runtime. This means you can inspect method parameters, call custom bean methods, combine multiple conditions with and/or, and write reusable authorization logic through expression root objects.
In short: @Secured is simpler but limited; @PreAuthorize is more powerful but has a steeper learning curve and slightly more overhead from SpEL parsing.
Spring Security's hasRole() function automatically prefixes its argument with ROLE_ before checking. So hasRole('ADMIN') looks for an authority of ROLE_ADMIN.
If the UserDetails implementation returns authorities as ADMIN (without the prefix) rather than ROLE_ADMIN, the check fails. The fix is either to use hasAuthority('ADMIN') or to ensure the UserDetails returns authorities with the ROLE_ prefix consistently.
Spring Security uses AOP proxies to intercept method calls and evaluate security annotations. When a bean calls one of its own annotated methods directly, it bypasses the proxy and the security check never runs.
For example, if OrderService.cancelOrder() internally calls this.deleteOrder(), the @PreAuthorize on deleteOrder is skipped because the call never goes through the proxy.
The solution is to extract the secured method to a separate bean so calls cross the bean boundary and hit the proxy, or switch from Spring AOP to AspectJ weaving which weaves at the bytecode level rather than using proxies.
Prefix any method parameter with # to make it available in the SpEL expression. For example, @PreAuthorize("#ownerId == authentication.principal.id") accesses the ownerId parameter directly.
Multiple parameters are supported: @PreAuthorize("#customerId == authentication.principal.id and #amount <= 1000").
You can also pass the entire parameter object: @PreAuthorize("@authz.canAccessDocument(authentication, #doc)") where #doc is the parameter and @authz references a Spring bean with the canAccessDocument method.
When a method annotated with @Async runs, Spring executes it in a separate thread from a thread pool. The SecurityContext is stored in a ThreadLocal, so it does not automatically propagate to the new thread. This means authentication is null inside the async method, and any @PreAuthorize check fails.
To fix this, configure your AsyncTaskExecutor to propagate the security context:
- Implement
AsyncConfigurerand overridegetAsyncExecutor() - Wrap the executor with a
SecurityContextAsyncTaskExecutor - Or use
setSecurityContextProvider()on aSimpleAsyncTaskExecutor
Alternatively, pass necessary authentication data as method parameters and perform the authorization check in the calling thread before launching the async operation.
@PreAuthorize evaluates authorization before the method body executes. Use it when the decision can be made based on the request context, method parameters, or principal properties alone.
@PostAuthorize evaluates authorization after the method runs, with access to the return value. Use it when the authorization decision actually depends on what the method returns, such as filtering a result set based on the caller's ownership of the returned object.
Example: @PostAuthorize("returnObject.owner == authentication.principal.username") inspects the returned entity to verify the caller owns it. This cannot be done with @PreAuthorize because the object does not exist until the method executes.
Default to @PreAuthorize. Only reach for @PostAuthorize when the return value is essential to the authorization decision.
Spring Security distinguishes between roles and authorities as separate concepts. Roles represent coarse-grained groupings like ADMIN or USER, while authorities represent fine-grained permissions like READ_DOCUMENTS or WRITE_CONFIG.
By convention, roles carry the ROLE_ prefix. This allows Spring Security to apply role-specific logic like the hasRole() method, which automatically prepends ROLE_ before performing the lookup. This prevents accidental overlap between roles and permissions in expression-based security.
Key rules: hasRole('ADMIN') looks for ROLE_ADMIN; hasAuthority('ADMIN') looks for an exact ADMIN match. Mixing these up is the most common source of authorization failures in Spring Security method security.
When @EnableMethodSecurity is present, Spring registers an AOP Alliance method interceptor around every bean method. Before the method executes, the interceptor:
- Extracts the SpEL expression from the annotation
- Creates a
MethodSecurityExpressionHandlerwith the currentAuthenticationobject as the expression root - Sets up the expression context with references to
authentication,principal,method, and any#parameternames from the method signature - Evaluates the expression to a boolean
- Throws
AccessDeniedExceptionif false, or proceeds to the target method if true
The SpEL expression is parsed and compiled on first use, then cached for subsequent calls to minimize overhead.
The SecurityContext is a ThreadLocal holder for the current Authentication object. It is populated by the SecurityFilterChain during request authentication and is what method security annotations inspect when evaluating authorization rules.
It is critical because method security interceptors read from SecurityContextHolder.getContext().getAuthentication() to obtain the current principal and their granted authorities. If the SecurityContext is null or contains an unauthenticated principal, authorization checks fail or deny access unexpectedly.
Common pitfalls: the SecurityContext does not propagate to child threads (async), it does not propagate across thread boundaries in @Scheduled tasks unless configured, and it is lost on the principal transition between synchronous and reactive stacks.
Start with this checklist in order:
- Is @EnableMethodSecurity present? Without it, annotations have no effect. This is the most common cause of ignored annotations.
- Is the call going through the proxy? Self-invocation (calling within the same bean) bypasses the proxy entirely. Verify the call comes from an external client or another bean.
- Is the principal authenticated? Anonymous users have no roles. Even a fully authorized principal will fail if the authentication object is null.
- Is the ROLE_ prefix correct? If
UserDetailsreturnsADMINbuthasRole('ADMIN')looks forROLE_ADMIN, the check fails silently. - Is @Async involved? The security context does not propagate into async threads by default, so method security runs with a null authentication.
- Enable DEBUG logging on
org.springframework.securityto see the actual authorization decisions being made.
No. Spring Security method security relies on Spring AOP, which works through JDK dynamic proxies or CGLIB proxies. Both proxy mechanisms can only intercept calls that go through the proxy — meaning the method must be externally callable on the bean.
Private methods are called directly on the target object within the bean, never crossing the proxy boundary. Similarly, static methods and final methods on classes cannot be intercepted.
If you need to secure internal logic, extract it to a separate bean so that calls to the secured method cross the bean boundary and are properly intercepted by the proxy.
Both annotations use AOP, and their order matters. Spring resolves this through a predefined advisor ordering. Transactional advice typically runs before security advice because it needs a clean connection to work with. This means:
- If a method is denied access by @PreAuthorize, the transaction never starts — which is correct behavior.
- If the method passes security but throws an exception, the transaction is still rolled back as expected.
However, if you use @PostAuthorize, the transaction has already committed by the time the post-authorization check runs. If @PostAuthorize throws AccessDeniedException after a transactional method succeeds, the data has already been persisted and cannot be rolled back by Spring's transaction infrastructure.
Avoid combining @Transactional with @PostAuthorize on methods that modify data. Use @PreAuthorize instead for authorization that must gate data changes.
@Secured performs a simple string comparison against the principal's granted authorities. This is O(n) where n is the number of authorities, but since authority lists are typically small (a handful of roles), the impact is negligible.
@PreAuthorize with SpEL introduces two overhead sources:
- Expression parsing: The SpEL expression string is parsed into an AST on first evaluation. Spring caches the compiled expression, so this cost is amortized across calls.
- Expression evaluation: Each invocation evaluates the expression against the current security context. Simple expressions like
hasRole('ADMIN')are fast. Complex expressions with method calls or nested property access add more overhead.
For most applications, the overhead is unmeasurable compared to the database and network latency in a typical request. Only in high-throughput, latency-sensitive code paths should this be a concern, and in those cases, caching the authorization result for the duration of the request is a common mitigation.
hasRole(String role) — Checks if the current principal has a role with the given name. It automatically prefixes the argument with ROLE_, so hasRole('ADMIN') checks for ROLE_ADMIN.
hasAuthority(String authority) — Checks for an exact match on the authority name without any prefix transformation. Use this when your UserDetails returns fine-grained permissions that do not follow the role naming convention.
hasAnyRole(String... roles) — Short-circuit OR across multiple roles. Like hasRole, each argument is prefixed with ROLE_. Equivalent to writing hasRole('A') or hasRole('B') but more concise.
There is no built-in hasAnyAuthority() function — you would need to chain multiple hasAuthority() calls with or.
Spring Security 5.2+ introduced reactive support for method security through @EnableReactiveMethodSecurity. Instead of ThreadLocal-based SecurityContext, reactive applications use ReactiveSecurityContextHolder which propagates context through the reactive pipeline via SubscriberContext.
The same annotations (@PreAuthorize, @Secured, etc.) work, but the authorization check returns a Mono<Boolean> that is resolved as part of the reactive pipeline. The method itself should return a reactive type (Mono or Flux), and the security check is applied per-item for Flux.
Key difference: the security context is not evaluated eagerly on a different thread. It is resolved from the reactive context when the pipeline executes, so there is no @Async problem in reactive applications — but context propagation still depends on the reactive context being properly set up by the security filter chain.
Further Reading
- Spring Security Documentation: Method Security — Official reference for
@EnableMethodSecurityand expression-based access control. - Spring Security Architecture: AOP and Proxies — Understanding how Spring AOP intercepts method calls and when proxy-based security is bypassed.
- JSR-250 Annotations in Jakarta EE — The official specification behind
@RolesAllowedand other JSR-250 security annotations. - SpEL Reference: Expression Interface — Full syntax for Spring Expression Language including method invocation, collection handling, and security expression root objects.
- Spring Security and @Async: Context Propagation — Common pitfalls when combining
@Asyncwith method security and the correct configuration pattern. - Custom Expression Methods in @PreAuthorize — Practical examples of building reusable authorization logic with
@Beanexpression methods.
Conclusion
Method-level security fills the gaps that URL-based security leaves open. URL rules gate which endpoints a user can reach. Annotations on individual methods decide what they can actually do once inside.
@Secured and @RolesAllowed handle simple role checks with minimal overhead. @PreAuthorize unlocks full SpEL expressiveness, letting you inspect parameters, call custom logic, and compose complex conditions. The tradeoff is a small amount of SpEL parsing overhead and a steeper learning curve.
The most common mistakes are the ROLE_ prefix mismatch, self-invocation bypassing the proxy, and @Async losing the security context. None of these are obvious until they bite you in production. Now you know where to look.
Layer method security on top of your SecurityFilterChain configuration, not instead of it. They reinforce each other.
Category
Related Posts
Spring Boot Build Tools: Maven & Gradle
Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.
Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow
Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.
JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests
Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.