In the first three parts of this series, every security decision happened before your controller ran.
The filter chain intercepted the HTTP request. JWT validation proved who you were. OAuth2 handed off your identity from Google. All of it — every authentication check, every URL-level access rule — happened at the HTTP boundary, before a single line of your business logic executed.
That’s a solid foundation. But it has a gap that URL patterns can’t fill.
Consider this: .requestMatchers("/invoices/").hasRole("USER")** Lets any authenticated user hit any invoice endpoint. But what if User A should only see their own invoices, not User B's? The URL pattern /invoices/4521 doesn't know who created invoice 4521. You need to be inside the method, with access to the arguments or the return value, to make that ownership decision.
That’s the problem method-level security solves. And it’s why it exists as a completely separate layer from the filter chain.
Enabling it — and why it’s off by default
Method-level security is disabled by default. You have to opt in:
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// rest of your config
}
Why off by default? Because enabling it causes Spring to create AOP proxies around every bean with a security annotation. That adds startup overhead and a small per-call interception cost. If you don’t need it, you don’t pay for it. Spring’s design philosophy is explicit opt-in for features that have a cost.
One thing worth knowing if you’re reading older code: @EnableMethodSecurity replaced @EnableGlobalMethodSecurity(prePostEnabled = true) in Spring Security 6. If you see the old annotation in a codebase, it still works, but it's deprecated. Don't use it for new projects — the behaviour is slightly different, and the new API is cleaner.
**
What Spring actually does when it sees @PreAuthorize
**
This is where most tutorials stop saying anything useful. They show you the annotation and assume you understand what fires it.
Here’s what actually happens.
When your Spring application starts and finds a bean with @PreAuthorize on a method, Spring doesn't modify your class. It creates a proxy object that wraps your bean. From that point on, any other bean in your application that injects InvoiceService actually receives the proxy, not the real service.
When something calls invoiceService.getInvoice(id), the call hits the proxy first. The proxy hands it to *AuthorizationManagerBeforeMethodInterceptor *— an AOP advice registered specifically to handle @PreAuthorize. This interceptor is the component that actually does the security check.
The interceptor goes to SecurityContextHolder and retrieves the current Authentication object. If you read Part 2 of this series, this is the same Authentication that your JWT filter stored there. If you read Part 3, it's the same one the OAuth2 login flow stored there. The filter chain populates it. Method security reads it. Same object, same store, different consumers.
The interceptor then evaluates the SpEL expression — the string inside the annotation — against that authentication. If it returns true, control passes to your real method. If it returns false, AccessDeniedException is thrown, caught by ExceptionTranslationFilter (from Part 1), and the client receives a 403.
Your actual service code only runs if all of that passes cleanly.
SpEL expressions — what you can actually write
The string inside @PreAuthorize is a Spring Expression Language (SpEL) expression evaluated at runtime. It has access to the current authentication, method arguments, and Spring beans.
The expressions you’ll use most often in production:
// Check for a role — Spring prepends "ROLE_" automatically
// This checks for ROLE_ADMIN, not ADMIN
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }
// Check for a specific authority - no prefix added
// Use this for fine-grained permissions
@PreAuthorize("hasAuthority('PAYMENT_WRITE')")
public void processPayment(Payment p) { ... }
// Access method arguments - the # prefix refers to parameter names
// This is the ownership check URL patterns can't do
@PreAuthorize("#userId == authentication.principal.id")
public Invoice getInvoice(Long userId, Long invoiceId) { ... }
// Call a Spring bean from inside the expression
// @ prefix followed by bean name
@PreAuthorize("@invoiceService.isOwner(#id, authentication.name)")
public Invoice getInvoice(Long id) { ... }
The difference between hasRole and hasAuthority trips up almost every developer at least once. hasRole('ADMIN') checks for the authority ROLE_ADMIN — Spring prepends the prefix automatically. hasAuthority('PAYMENT_WRITE') checks for the exact string. If you store custom permissions in your authorities list without the ROLE_ prefix, use hasAuthority. If you're working with standard roles granted via ROLE_ conventions, use hasRole.
The #userId == authentication.principal.id pattern is the most valuable thing method security enables. You can't express "the requested userId matches the logged-in user's id" at the URL-matcher level — but you can express it right here, next to the method that enforces it. That's both more secure (the check is right next to the code it protects) and more readable.
**
@PostAuthorize — when you need the return value
**
@PostAuthorize runs after the method executes. It has access to the return value through the returnObject keyword:
// Only return the invoice if the logged-in user owns it
@PostAuthorize("returnObject.ownerId == authentication.principal.id")
public Invoice getInvoice(Long id) {
return invoiceRepository.findById(id).orElseThrow();
}
The use case: you need to fetch a record from the database to know who owns it. You don’t want to run a separate ownership query before the fetch — that’s two queries instead of one. So you fetch it, then check.
The gotcha — and this one matters: the method always runs. If it has side effects — writing to a database, sending an email, calling an external service — those side effects happen even if @PostAuthorize denies access afterward. Only use @PostAuthorize on read-only operations where running the method and then rejecting the result is safe.
**
Where method security fits — the full picture
**
Parts 1 through 4 have built a three-layer security system. This diagram shows how they connect.

Layer 1 is the filter chain. It operates at the HTTP boundary, before your controller. FilterChainProxy picks the right SecurityFilterChain. AuthorizationFilter applies URL-level access rules. Requests that fail here never reach your code.
Layer 2 is authentication. JWT validation via OncePerRequestFilter (Part 2) or OAuth2 login via OAuth2LoginAuthenticationFilter (Part 3) — both populate SecurityContextHolder with the verified identity. This is the layer that answers "who is this person."
Layer 3 is method security. The AOP proxy intercepts service-layer method calls. @PreAuthorize Reads from the same SecurityContextHolder that Layer 2 populated. Fine-grained, data-aware decisions happen here — decisions that Layer 1 couldn't make because they depend on method arguments or return values, not just URL patterns.
All three layers share one SecurityContextHolder. Layer 2 writes to it. Layers 1 and 3 read from it. The filter chain and method security are not two separate security systems bolted together. They're two consumers of the same authentication store, operating at different points in the request lifecycle.
@Secured and @RolesAllowed — what they are and why @PreAuthorize wins
Two older annotations you’ll encounter in legacy code:
// Spring's older annotation — no SpEL, role checks only
@Secured({"ROLE_ADMIN"})
public void deleteUser(Long id) { ... }
// JSR-250 standard - not Spring-specific, same limitations
@RolesAllowed("ADMIN")
public void deleteUser(Long id) { ... }
@Secured is Spring-specific, no SpEL. @RolesAllowed is the JSR-250 standard, also no SpEL. Both work for simple role checks. Neither lets you access method arguments, call Spring beans, or check return values.
@PreAuthorize With full SpEL support does everything both of those do and more. For new code, there's no reason to use either of the older annotations. Enable @EnableMethodSecurity and use @PreAuthorize.
When NOT to use method-level security — and the proxy trap
The self-invocation proxy trap is the most common @PreAuthorize bug in production.
If a method inside a bean calls another annotated method in the same bean, the proxy is bypassed. The call goes directly to the real object, skipping the AOP interceptor entirely. The @PreAuthorize annotation silently does nothing.
@Service
public class InvoiceService {
public void processAll() {
// This calls the REAL method directly - not the proxy
// @PreAuthorize on getInvoice will NOT fire
this.getInvoice(1L);
}
@PreAuthorize("hasRole('ADMIN')")
public Invoice getInvoice(Long id) {
return invoiceRepository.findById(id).orElseThrow();
}
}
The fix: inject the service into itself via Spring (which gives you the proxy), or move the annotated method to a different bean.
Don’t put complex ownership logic in SpEL. Calling @userService.isOwner(#id, authentication.name) from inside an annotation is hard to test, hard to debug, and invisible during code reviews. If the expression is more than one condition, extract it into a properly tested method.
Don’t use @PostAuthorize on methods with side effects. The method runs regardless. If you can’t afford the side effect when access is denied, use @PreAuthorize with a pre-fetch ownership check instead.
Method security doesn’t replace the filter chain. Both layers are needed. The filter chain handles unauthenticated requests — turning them away before they reach any code at all. Method security handles authorisation decisions that require data context the filter chain doesn’t have. They’re complementary layers of the same system, not alternatives to each other.
The series in three sentences
The filter chain decides if you can enter the building. JWT or OAuth2 proves who you are at the door. @PreAuthorize decides what rooms you can open once you're inside. Same security context. Three different places it applies.

Top comments (0)