Method-Level Security
URL-level authorization stops at the controller. This chapter is @PreAuthorize, @PostAuthorize, and filtering collections — enforcing rules at the exact method (or even list-of-results) level.
Learning objectives
- Beginner: Explain why URL-level security alone isn't always enough for a given endpoint.
- Intermediate: Apply @PreAuthorize and @PostAuthorize correctly to service methods.
- Advanced: Diagnose why a @PreAuthorize check gets silently skipped when called from another method in the same class.
🌱 BEGINNER STORY
URL-based security is like a security guard at the FRONT DOOR of the bank — they check your ID once to let you into the 'Loans Department' area. But once inside, imagine a teller who ALSO double-checks, for every single specific action you try to take at their desk — 'you want to approve THIS SPECIFIC loan? Let me verify you personally have approval rights for loans over $10,000, not just general access to this department.' That per-action, per-method check — happening deep inside the business logic, not just at the front door — is exactly what method-level security provides.
requestMatchers()-based authorization protects entire URL PATTERNS. But real business logic often needs finer control: 'any authenticated user can call GET /api/loans/{id}, but they should only see loans belonging to THEM' — a check that depends on the specific loan ID and the specific user, not just the URL shape. Method-level security lets you express these rules directly on the SERVICE method itself, close to the actual business logic, using annotations.
Figure: Spring wraps annotated beans in an AOP proxy that checks the security rule BEFORE (or AFTER) the real method executes.
@Configuration
@EnableMethodSecurity // replaces the old @EnableGlobalMethodSecurity public class MethodSecurityConfig { }
@EnableMethodSecurity (Spring Security 6+) enables @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter, and @Secured out of the box using Spring AOP proxies — this is why method security ONLY works on methods called through the Spring-managed proxy (e.g., calling a method on an injected @Service bean from a DIFFERENT bean) and does NOT work on self-invocation (a class calling its own method internally bypasses the proxy entirely).
@Service
public class LoanService { @PreAuthorize("hasRole('ADMIN')") public void approveLoan(Long loanId) { // only executes if the check passes } @PreAuthorize("#loanId == authentication.principal.customerId or hasRole('ADMIN')") public Loan getLoan(Long loanId) { // custom SpEL expression referencing the method argument directly return loanRepository.findById(loanId).orElseThrow(); } }
@PreAuthorize expressions are evaluated using Spring Expression Language (SpEL) BEFORE the method body runs — if the expression evaluates to false, the method never executes at all, and an AccessDeniedException is thrown immediately. This is ideal for 'should this operation even be attempted' checks.
@PostAuthorize("returnObject.ownerUsername == authentication.name")
public Account getAccountDetails(Long accountId) { return accountRepository.findById(accountId).orElseThrow(); }
@PostAuthorize lets the method RUN FIRST (fetching the actual object), and then checks the RESULT (via the returnObject variable) against a rule — necessary when the authorization decision depends on data you can only know AFTER fetching it (e.g., 'is the account I just fetched actually owned by the caller?'). The tradeoff: the method's side effects (like a DB read, or worse, a write) have already happened even if the check ultimately fails — so @PostAuthorize is inappropriate for methods with expensive or irreversible side effects.
@PreFilter("filterObject.ownerUsername == authentication.name")
public void processTransactions(List transactions) { // Spring Security removes any Transaction from the incoming list // where the SpEL expression evaluates to false, BEFORE the method runs. } @PostFilter("filterObject.ownerUsername == authentication.name") public List getAllAccounts() { // Fetches ALL accounts (e.g., from DB), then Spring Security // strips out any Account in the returned list that fails the check. return accountRepository.findAll(); }
⚠ Performance trap with @PostFilter
@PostFilter fetches the FULL collection first (e.g., every account in the database) and only filters it down in memory afterward — this is fine for small datasets but a serious performance and scalability problem at real production scale. Prefer pushing the filtering condition into the actual database QUERY (e.g., a repository method like findByOwnerUsername()) whenever possible, reserving @PostFilter for cases where the filtering logic genuinely can't be expressed at the query level.
Production systems typically layer BOTH URL-level and method-level security together: URL rules provide a coarse first line of defense (e.g., 'must be authenticated to reach any /api/loans/** endpoint at all'), while method-level annotations enforce the finer, business-specific rule ('but you can only approve a loan if you specifically hold loan-approval authority AND the loan amount is within your approval limit'). Relying on only one layer is a common source of security gaps — e.g., a controller method might forget its own explicit check, but a @PreAuthorize on the underlying service method still protects the actual business operation regardless of which controller (or future new controller) calls it.
⚠ Where method security silently fails
Self-invocation (calling this.someOtherMethod() from within the same class) completely bypasses the AOP proxy, and therefore bypasses @PreAuthorize/@PostAuthorize entirely — a well-known and frequently-missed gap. Refactor such calls into a separate, appropriately-annotated bean if security enforcement is required. @PostAuthorize incurs a real cost: since the method has already executed by the time the check runs, using it on methods with side effects (e.g., a method that both fetches AND updates a record) can let unauthorized state changes happen even though the final response is blocked. SpEL expressions that reference method parameters must match the ACTUAL parameter name (or use explicit @P('name') annotations) — refactoring a method's parameter names without updating the corresponding @PreAuthorize expression silently breaks the check (usually throwing a SpEL evaluation error at runtime, or worse, being silently ignored depending on configuration). Method security exceptions (AccessDeniedException) thrown deep inside a service layer still need to be translated properly at the web layer — without a consistent @ControllerAdvice or the standard AccessDeniedHandler wiring, they can leak as raw 500 errors instead of clean 403 responses.
Want a visual for this concept?
Generate a diagram tailored to “Method-Level Security” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →