Method Security: Preauthorization and Postauthorization
Objective
Endpoint-level authorization answers "may this HTTP request proceed?" — but a Spring bean method can be reached from many places that aren't an HTTP request: a scheduled job, a message listener, another service, a repository call. Method security moves the authorization decision onto the method call itself. Spring Security registers an AOP interceptor around annotated methods and evaluates a SpEL rule either before the call (preauthorization — the method never runs) or after it (postauthorization — the method runs, but the caller may not receive the result). The authority/role model is unchanged; only the place where the rule is attached moves, from a filter-chain DSL to an annotation sitting next to the code it protects.
Use Cases
- Enforcing a permission on a service or repository method that is called from more than one entry point, so the rule can't live on a single controller mapping — the check travels with the method, not with the URL.
- Expressing a rule that depends on a method argument rather than on the
request:
findSecretNames(name)may only be called whennameequals the authenticated user's own username. - Expressing a rule that can only be decided after the data is loaded —
"return this employee record only if the loaded record carries the
readerrole" — where the deciding fact isn't known until the method returns. - Object-level ("does this user own that document") authorization that is
too complex for a one-line expression, delegated to a
PermissionEvaluatoror a customAuthorizationManagerin its own class. - Protecting non-web entry points entirely — a
@Scheduledtask or a message handler has noHttpServletRequest, so anauthorizeHttpRequestsrule can never apply to it.
Deep Dive
Call authorization is an aspect, not a filter
Request-level authorization runs inside the servlet filter chain. Method security instead enables a Spring AOP aspect: the caller's reference to the bean is a proxy, and the interceptor sits between caller and target.
plaintextDocumentController ──▶ [ security interceptor ] ──▶ DocumentService │ └── rule fails → AccessDeniedException, target never called
Two things follow directly from that mechanism. First, the rule applies to
every caller of the bean, web or not. Second — the classic AOP caveat — a
self-invocation inside the same class bypasses the proxy entirely, so an
internal this.getDocument(code) call is not intercepted.
The book splits method security into two families: call authorization
(this concept — allow or reject the call, or reject its result) and
filtering (@PreFilter/@PostFilter, which let the call through but prune
collections in and out). Filtering is covered separately in
spring-security-method-security-filtering-and-spring-data.
Enabling it, and what "enabled" means
Method security is off by default — Spring Boot's security starter does not activate it. One annotation on a configuration class turns it on:
java@Configuration
@EnableMethodSecurity
public class SecurityConfig {
}That single annotation enables @PreAuthorize, @PostAuthorize, @PreFilter
and @PostFilter (its prePostEnabled attribute defaults to true). The two
legacy alternatives stay opt-in: securedEnabled = true for Spring's own
@Secured, jsr250Enabled = true for JSR-250's @RolesAllowed /
@PermitAll / @DenyAll. Both are strictly less expressive than the pre/post
annotations — they take role names, not expressions — and are rarely worth
choosing for new code.
@PreAuthorize: authorities, roles, and method arguments
The simplest form reuses the exact same expression vocabulary as request-level
authorization — hasAuthority, hasAnyAuthority, hasRole, hasAnyRole,
hasAllAuthorities, hasAllRoles, permitAll, denyAll, including the
ROLE_ prefix asymmetry documented in
spring-security-authorization-authorities-and-roles:
java@Service
public class NameService {
@PreAuthorize("hasAuthority('write')")
public String getName() {
return "Fantastico";
}
}A user holding write gets the value; a user holding only read gets a 403
and getName() is never entered.
What method security adds over the request-level DSL is access to the
invocation itself. A #name reference in the expression resolves to the
method parameter of that name, and authentication resolves to the current
Authentication:
java@Service
public class NameService {
private final Map<String, List<String>> secretNames = Map.of(
"natalie", List.of("Energico", "Perfecto"),
"emma", List.of("Fantastico"));
@PreAuthorize("#name == authentication.principal.username")
public List<String> getSecretNames(String name) {
return secretNames.get(name);
}
}Emma can read /secret/names/emma but gets 403 on
/secret/names/natalie — a rule that no authority check can express, because
it compares an argument against the principal rather than testing a static
permission.
One modern prerequisite for #name to resolve at all: parameter names must
survive compilation. Spring Framework 6.1 removed
LocalVariableTableParameterNameDiscoverer, so the code must either be
compiled with -parameters or annotate the parameter explicitly with
Spring Security's @P (or Spring Data's @Param):
java@PreAuthorize("hasPermission(#c, 'write')")
public void updateContact(@P("c") Contact contact);@PostAuthorize: rules over returnObject
Sometimes the fact the decision hinges on doesn't exist until the method has
run — you can't know an employee record carries the reader role before
loading it. @PostAuthorize runs the method, then evaluates its expression
against the special returnObject variable:
java@Service
public class BookService {
@PostAuthorize("returnObject.roles.contains('reader')")
public Employee getBookDetails(String name) {
return records.get(name);
}
}If the expression is false, the interceptor discards the result and throws
instead of returning it. The current reference documents the same idiom for the
ownership case: @PostAuthorize("returnObject.owner == authentication.name").
Both annotations may sit on the same method when a call needs a pre-check and a result-check.
The sharp edge is that "the method already ran" is literally true. Anything the
method mutated stays mutated. The book notes that even @Transactional does not
save you — the postauthorization exception is thrown after the transaction
manager commits, so there is nothing left to roll back. The current reference
states the same rule as advice: @PostAuthorize is not recommended on classes
that perform database writes; read first with @PostAuthorize on the read, then
write only if that read was authorized.
Beyond one-line SpEL: hasPermission() and PermissionEvaluator
Long SpEL strings are unreadable and untestable. When the rule needs real logic
— "an admin, or the owner of this document" — hasPermission() hands off to a
PermissionEvaluator bean:
javapublic interface PermissionEvaluator {
boolean hasPermission(Authentication a, Object subject, Object permission);
boolean hasPermission(Authentication a, Serializable id, String type, Object permission);
}Two shapes, matching two moments. The object form suits @PostAuthorize,
where the subject is the loaded result:
java@PostAuthorize("hasPermission(returnObject, 'ROLE_admin')")
public Document getDocument(String code) {
return documentRepository.findDocument(code);
}java@Component
public class DocumentsPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication,
Object target, Object permission) {
Document document = (Document) target;
String requiredRole = (String) permission;
boolean admin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(requiredRole));
return admin || document.getOwner().equals(authentication.getName());
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId,
String targetType, Object permission) {
return false;
}
}The id + type form suits @PreAuthorize, where the object doesn't exist yet
and only its identifier is in hand — the evaluator loads it itself:
java@PreAuthorize("hasPermission(#code, 'document', 'ROLE_admin')")
public Document getDocument(String code) {
return documentRepository.findDocument(code);
}Note what is not in the expression: the Authentication. Spring Security
supplies it from the SecurityContext when it calls the evaluator; the
expression only passes the subject and the permission token.
Registering the evaluator is the one place where the modern configuration genuinely differs from the book, and it is easy to get wrong — see below.
Book vs. today: @EnableGlobalMethodSecurity → @EnableMethodSecurity
The book enables method security with:
java@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
}@EnableGlobalMethodSecurity is deprecated; the current reference says it
is superseded by @EnableMethodSecurity (introduced in 5.6) and that users are
encouraged to migrate. The differences the docs actually call out:
- Pre/post annotations are on by default.
@EnableMethodSecurityalone is equivalent to@EnableGlobalMethodSecurity(prePostEnabled = true), and it enables@PreFilter/@PostFiltertoo. Conversely, if you only wanted@Secured, the old@EnableGlobalMethodSecurity(securedEnabled = true)becomes@EnableMethodSecurity(securedEnabled = true, prePostEnabled = false)— the old implicit "off" is now an explicit opt-out. AuthorizationManagerreplaces the voter/decision-manager stack. Each annotation now has its own dedicated interceptor (AuthorizationManagerBeforeMethodInterceptor#preAuthorizewithPreAuthorizeAuthorizationManager,AuthorizationManagerAfterMethodInterceptor#postAuthorizewithPostAuthorizeAuthorizationManager) built on native Spring AOP, instead of metadata sources, config attributes, decision managers and voters. It also checks for conflicting annotations and complies fully with JSR-250.- The
Authenticationlookup is deferred. The expression handler now takes aSupplier<Authentication>, so the lookup happens only if the expression needs it — applied automatically under@EnableMethodSecurity. - A custom
PermissionEvaluatorbean is no longer auto-detected. This is the trap for anyone porting section 16.4 verbatim. The book extendsGlobalMethodSecurityConfigurationand overridescreateExpressionHandler(). The migration guide is explicit that@EnableMethodSecuritydoes not pick up aPermissionEvaluator, to keep its API simple — you must publish aMethodSecurityExpressionHandlerbean that wires it in, and it must bestaticso it initializes early enough:Silently, a missing registration leaves the defaultjava@Bean static MethodSecurityExpressionHandler methodSecurityExpressionHandler( DocumentsPermissionEvaluator evaluator) { var handler = new DefaultMethodSecurityExpressionHandler(); handler.setPermissionEvaluator(evaluator); return handler; }DenyAllPermissionEvaluatorin place — everyhasPermission()returns false. - Subclassing hook moved. Code that extended
DefaultMethodSecurityExpressionHandlerand overrodecreateSecurityExpressionRoot(Authentication, MethodInvocation)no longer works; the new arrangement callscreateEvaluationContext(Supplier<Authentication>, MethodInvocation).
What did not change: the SpEL itself. hasAuthority, hasRole,
#parameterName, authentication, returnObject and hasPermission all read
exactly as in the book, and PermissionEvaluator is still the documented hook
for object-level authorization — it was not replaced. What was added is a more
general extension point: you can publish your own
AuthorizationManager<MethodInvocation> behind a custom pointcut when even a
PermissionEvaluator is the wrong shape, and @HandleAuthorizationDenied with
a MethodAuthorizationDeniedHandler lets a denial return a masked or null
value instead of throwing — useful when the denied field is part of a JSON
response.
Trade-offs
- The rule lives next to the code it protects — that is both the selling point and the cost. An annotation on the service method is impossible to miss when reading that method, and it covers every caller. But the application's authorization policy is now scattered across the codebase instead of readable in one configuration class, and the current reference frames exactly this as the core trade-off: request-level is coarse-grained and centralized in a DSL, method-level is fine-grained and local, in annotations and SpEL.
- Unannotated methods are simply not secured. Method security has no
anyRequest()equivalent — there is no catch-all. The docs are explicit that you should still declare a catch-all rule inHttpSecurityso a forgotten annotation isn't an open door. Method security complements request-level authorization; it does not replace it. - It is AOP, so it inherits AOP's blind spot. A call from inside the same bean doesn't go through the proxy and isn't checked. This surprises people far more often at the method level than at the filter level, because the code looks protected.
@PostAuthorizeprotects the result, never the side effects. The method has already run and, with@Transactional, already committed by the time the check fails — the book flags this and the current docs turn it into a recommendation not to combine@PostAuthorizewith database writes. Prefer@PreAuthorizewhenever the deciding fact is available before the call; reach for@PostAuthorizeonly when it genuinely isn't.- SpEL is powerful and unchecked.
#name == authentication.principal.usernamecompiles to nothing — a renamed parameter, or a build without-parameters, breaks the rule at runtime rather than at compile time.@P("name")pins the name explicitly and is worth the noise on anything security-critical. - Push non-trivial logic out of the expression, but pick the right hook.
PermissionEvaluatoris the well-trodden path for "this user vs. that object" and is still current; a customAuthorizationManageris the more general (and more modern) escape hatch. Either way, logic in a class is unit-testable — a three-clause SpEL string is not. @Securedand@RolesAllowedexist, and are almost never the right choice. They accept role names only: no arguments, noreturnObject, no expressions. The book's own verdict is that you're unlikely to meet them in real-world code; their main use today is interoperability with existing JSR-250 annotations.
Documentation Links
- Laurențiu Spilcă, "Spring Security in Action" (Manning, 2020) — Chapter 16, "Global method security: Pre- and postauthorizations", sections 16.1-16.4, p. 388-412
- Spring Security Reference — Method Security
- Spring Security API — EnableMethodSecurity
- Spring Security API — EnableGlobalMethodSecurity (deprecated)
- Spring Security 5.8 Migration Guide — Authorization Migrations
- Spring Security 6.5 Migration Guide — Compile With -parameters