Spring Security Method Security: @PreAuthorize, @PostAuthorize, @Secured Guide
Master Spring Security method-level annotations: @PreAuthorize, @PostAuthorize, and @Secured.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17+ (Spring Boot 3.x) or Java 11+ (Spring Boot 2.x)
- ✓Spring Security basics: authentication, SecurityFilterChain, UserDetailsService
- ✓Maven or Gradle build tool with spring-boot-starter-security dependency
- ✓Basic understanding of Spring Expression Language (SpEL)
• @PreAuthorize checks access before method execution using SpEL expressions • @PostAuthorize checks access after method execution, often used for return value filtering • @Secured is a simpler, role-based annotation (Spring Security 5.x+ with @EnableMethodSecurity) • All require @EnableMethodSecurity (or @EnableGlobalMethodSecurity in older versions) • Common pitfalls: forgetting to enable method security, using wrong SpEL syntax, mixing annotation types
Think of method security like a VIP club with different access levels. @PreAuthorize is the bouncer who checks your ID before you enter the club. @PostAuthorize is the bouncer who checks your ID after you've already grabbed a drink — maybe to verify you're allowed to have that specific drink. @Secured is like a simple wristband system: if you have the right color wristband, you're in. No fancy logic, just role-based entry.
Method-level security is where Spring Security moves from 'good enough' to 'production-ready.' After years of building payment-processing systems and SaaS billing platforms, I've learned that URL-based security alone is a disaster waiting to happen. You might secure /api/orders, but what about the service layer method that processes refunds? That's where @PreAuthorize, @PostAuthorize, and @Secured come in.
These annotations let you enforce security at the method level, deep inside your business logic. @PreAuthorize evaluates a SpEL expression before the method runs — perfect for checking if the current user has the 'ROLE_ADMIN' or owns the resource. @PostAuthorize runs after the method, giving you access to the return value for fine-grained checks. @Secured is the simpler, older sibling — it just checks roles without SpEL.
In Spring Security 6.0+, you need @EnableMethodSecurity to activate these annotations. If you're still on Spring Security 5.x with @EnableGlobalMethodSecurity, it's time to upgrade — the new approach is cleaner and supports method-level authorization with less boilerplate.
This tutorial covers real-world usage, gotchas from production incidents, and debugging techniques. By the end, you'll know exactly when to use each annotation and how to avoid the mistakes that cause security holes or 403 errors in production.
Setting Up Method Security in Spring Boot 3.x
To use @PreAuthorize, @PostAuthorize, and @Secured, you must first enable method security. In Spring Boot 3.x (Spring Security 6.x), this is done with @EnableMethodSecurity on any @Configuration class. This replaces the older @EnableGlobalMethodSecurity from Spring Security 5.x.
Here's the minimum setup. First, add the dependency. Then create a security configuration class. The @EnableMethodSecurity annotation activates the pre/post annotations by default. If you also want @Secured, you need to set securedEnabled = true.
Important: In Spring Security 6, the default behavior changed. Previously, @EnableGlobalMethodSecurity(prePostEnabled = true) was required. Now, @EnableMethodSecurity is the way to go. If you're migrating from 5.x, remove the old annotation and replace it with this one.
Also note: The annotation works on both interfaces and classes, but I strongly recommend putting it on the implementation class to avoid proxy issues. I've debugged countless issues where @PreAuthorize on an interface method didn't work because of AOP proxy configuration.
What the Official Docs Won't Tell You
The Spring Security reference docs are thorough, but they skip the real-world pain points. Here's what I've learned from production:
First, @PreAuthorize expressions are evaluated against the Authentication object in the SecurityContextHolder. If you're using custom authentication (e.g., JWT tokens), make sure your GrantedAuthority objects are populated correctly. I've seen teams spend hours debugging why hasRole('ADMIN') returns false, only to find they forgot to prefix roles with 'ROLE_' in the token.
Second, @PostAuthorize lets you reference the method return value using the 'returnObject' variable. This is incredibly powerful for data-level security. For example, you can check if the returned entity belongs to the current user. But beware: @PostAuthorize runs after the method, so if your method is expensive (e.g., database query), you're wasting resources if access is denied.
Third, @Secured is often misunderstood. It doesn't support SpEL — it only checks for role names. And it requires the 'ROLE_' prefix. So @Secured("ADMIN") won't work; you need @Secured("ROLE_ADMIN"). This is a common source of bugs.
Finally, method security works via AOP proxies. If you call a method within the same class (self-invocation), the annotation won't trigger. This is the #1 cause of 'why isn't my @PreAuthorize working?' in Stack Overflow questions.
@PreAuthorize Deep Dive: SpEL Expressions and Custom Permission Evaluators
@PreAuthorize is the most flexible annotation because it accepts any SpEL expression that evaluates to a boolean. You can check roles, permissions, method arguments, and even call custom beans.
- authentication: The current Authentication object
- principal: The principal from Authentication (often a UserDetails or custom object)
- #argumentName: Method arguments, accessed by name
- hasRole(), hasAnyRole(), hasAuthority(), hasPermission(): Built-in methods
For complex logic, create a custom PermissionEvaluator and register it with the MethodSecurityExpressionHandler. This keeps your annotations clean and testable.
One production pattern I use: combine @PreAuthorize with a custom @CurrentUser annotation to inject the authenticated user. Then use #user in expressions. This avoids accessing authentication.principal directly, which can be messy with custom principals.
Important: In Spring Security 6, the default role prefix is 'ROLE_'. If you're using custom roles without this prefix, use hasAuthority() instead of hasRole().
@PostAuthorize: Securing Return Values and Preventing Data Leakage
@PostAuthorize runs after the method executes, giving you access to the return value via the 'returnObject' variable. This is useful when you can't determine access before execution — for example, when the method returns a list and you need to filter based on the current user.
However, @PostAuthorize has a dark side: it doesn't prevent the method from executing. If your method writes to a database or calls an external API, those side effects happen even if @PostAuthorize throws an AccessDeniedException. This can lead to data corruption or inconsistent state.
Use @PostAuthorize for read-only operations or when the security decision truly depends on the return value. For write operations, always prefer @PreAuthorize.
One advanced pattern: combine @PostAuthorize with a collection return type. You can filter the collection using SpEL, but be warned — this is not lazy. The entire collection is loaded into memory before filtering. For large datasets, this is a performance disaster.
In Spring Security 6, @PostAuthorize also supports the 'returnObject' for reactive types (Mono/Flux) with Project Reactor. But that's a topic for another article.
@Secured: The Simple Role-Based Alternative
@Secured is the simplest annotation: it just checks if the current user has one or more roles. No SpEL, no custom expressions. It's perfect for straightforward role-based access where you don't need complex logic.
To use @Secured, you must enable it with securedEnabled = true in @EnableMethodSecurity (or @EnableGlobalMethodSecurity). The annotation takes an array of role names, and the user must have at least one of them.
Important: @Secured requires the 'ROLE_' prefix. If your roles are stored without this prefix (e.g., 'ADMIN' instead of 'ROLE_ADMIN'), use hasAuthority() in @PreAuthorize instead.
When should you use @Secured vs @PreAuthorize? If your requirement is "only users with ROLE_ADMIN can call this method," @Secured is cleaner. If you need "users with ROLE_ADMIN OR users who own the resource," use @PreAuthorize.
In Spring Security 6, @Secured is fully supported but considered less flexible. The trend is toward @PreAuthorize for everything. But for legacy systems or simple apps, @Secured is fine.
One gotcha: @Secured on an interface method may not work if the proxy is JDK dynamic proxy (based on interfaces). Use CGLIB proxies (proxyTargetClass = true) or put the annotation on the implementation class.
Combining Annotations and Custom Security Expressions
You can combine @PreAuthorize, @PostAuthorize, and @Secured on the same method, but this is rarely necessary. If you do, the order of evaluation is: @Secured first, then @PreAuthorize, then @PostAuthorize. If any check fails, an AccessDeniedException is thrown.
A more practical approach is creating custom security annotations using meta-annotations. For example, you can define @AdminOnly that combines @PreAuthorize with a specific role check. This reduces duplication and makes your code more readable.
Spring Security also supports custom method security expressions. By implementing MethodSecurityExpressionOperations, you can add custom methods like 'isMemberOfTenant()' or 'canApprovePayment()'. Register them via a custom MethodSecurityExpressionHandler.
In production, I've found that custom expressions are invaluable for multi-tenant systems. Instead of repeating @PreAuthorize("authentication.principal.tenantId == #tenantId") everywhere, I create a @CurrentTenant annotation and a custom expression like '@tenantSecurity.isCurrentTenant(#tenantId)'.
One warning: custom expressions require careful testing. A bug in your expression handler can silently grant or deny access. Write unit tests for every custom expression.
Testing Method Security: Unit and Integration Tests
Testing method security is non-negotiable. I've seen too many bugs slip into production because developers only tested controller-level security. You need to test the annotations at the service layer.
For unit tests, use Spring's SecurityTestUtils or Mockito to set up the SecurityContext. You can mock an Authentication object with specific roles and call the service method directly. But be aware: method security only works if the service is a Spring proxy. In a plain unit test without Spring context, @PreAuthorize won't fire.
For integration tests, use @WithMockUser or @WithUserDetails to simulate authenticated users. The @SpringBootTest annotation will load the full application context, including AOP proxies. This is the most reliable way to test method security.
Spring Security 6.1 introduced @WithAnonymousUser for testing unauthenticated access. Use it to verify that your methods are properly secured against anonymous users.
One advanced technique: test negative cases. Verify that users without the required role get an AccessDeniedException. Don't just test the happy path. In a payment system, we once found that a @PreAuthorize expression had a typo — 'hasRole('ADMIN')' was written as 'hasRole('ADMIN')' (same, but the bean method had a bug). Only negative testing caught it.
Common Pitfalls and Production Debugging
After years of debugging Spring Security issues, here are the most common problems with method security and how to fix them.
- Annotation not working: Most likely, you forgot @EnableMethodSecurity. Check your configuration class. Also verify that the method is called through a Spring proxy (not self-invocation).
- AccessDeniedException even with correct role: Check the role prefix. hasRole('ADMIN') expects 'ROLE_ADMIN' in the GrantedAuthority. If your authorities don't have the prefix, use hasAuthority('ADMIN').
- SpEL parsing errors: Syntax errors in SpEL cause IllegalArgumentException at runtime. Use a SpEL parser like SpelExpressionParser to test expressions offline.
- @PostAuthorize not filtering: Remember that @PostAuthorize doesn't modify the return value. It only throws an exception if the expression is false. For filtering collections, you need to implement custom logic.
- Performance issues: Complex SpEL expressions on high-traffic methods can degrade performance. Profile with Spring Boot Actuator or a profiler like async-profiler.
For debugging, enable DEBUG logging for org.springframework.security.access. This shows which annotations are evaluated and why access is denied. Also, use SecurityContextHolder.getContext().getAuthentication() in your code to inspect the current authentication state.
The $50,000 Refund Bug: How Missing @PreAuthorize Cost a SaaS Company
- Never rely solely on controller-level security — always protect service methods with @PreAuthorize or @PostAuthorize
- Use method security as a defense-in-depth layer, not a replacement for URL security
- Audit all security-sensitive operations at the service layer
logging.level.org.springframework.security.access=DEBUGSecurityContextHolder.getContext().getAuthentication().getAuthorities()| File | Command / Code | Purpose |
|---|---|---|
| SecurityConfig.java | @Configuration | Setting Up Method Security in Spring Boot 3.x |
| OrderService.java | @Service | What the Official Docs Won't Tell You |
| PaymentService.java | @Service | @PreAuthorize Deep Dive |
| DocumentService.java | @Service | @PostAuthorize |
| AdminService.java | @Service | @Secured |
| CustomSecurityAnnotations.java | @Target({ElementType.METHOD, ElementType.TYPE}) | Combining Annotations and Custom Security Expressions |
| PaymentServiceTest.java | @SpringBootTest | Testing Method Security |
| application.yml | logging: | Common Pitfalls and Production Debugging |
Key takeaways
Interview Questions on This Topic
Explain the difference between @PreAuthorize and @Secured. When would you use each?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring Security. Mark it forged?
6 min read · try the examples if you haven't