Method-Level Security
URL-based rules in authorizeHttpRequests protect endpoints, but they can’t easily express rules that depend on method arguments or the returned object — “a user may edit only their own profile,” for example. Method-level security moves authorization onto the service layer using annotations evaluated with SpEL (Spring Expression Language), giving you fine-grained, reusable checks close to the business logic.
Enabling method security
Add @EnableMethodSecurity to a @Configuration class. In Spring Security 6 this annotation enables @PreAuthorize/@PostAuthorize by default; opt in to the legacy annotations explicitly.
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity(
prePostEnabled = true, // @PreAuthorize / @PostAuthorize (default true)
securedEnabled = true) // @Secured (default false)
public class MethodSecurityConfig { }
Note:
@EnableMethodSecurityreplaces the old@EnableGlobalMethodSecurity. It uses Spring’s standard AOP (CGLIB/JDK proxies) under the hood.
@PreAuthorize
@PreAuthorize runs before the method, rejecting the call with AccessDeniedException if the SpEL expression is false. This is the workhorse.
import org.springframework.security.access.prepost.PreAuthorize;
@Service
public class AccountService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteAccount(Long id) { ... }
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public Account view(Long id) { ... }
// Argument-based: a user may update only their own account
@PreAuthorize("#id == authentication.principal.id")
public void update(Long id, AccountForm form) { ... }
// Combine conditions
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public void reset(Long id) { ... }
}
For #id == authentication.principal.id to work, the principal must be your UserDetails implementation exposing an id getter — for instance the UserAccount entity from database authentication.
@PostAuthorize
@PostAuthorize runs after the method and can inspect the return value via returnObject. Use it when the authorization decision depends on what was loaded.
@PostAuthorize("returnObject.ownerUsername == authentication.name")
public Document getDocument(Long id) {
return documentRepository.findById(id).orElseThrow();
}
Warning:
@PostAuthorizeexecutes the method first, then denies. Don’t use it for operations with side effects (writes, external calls) — the work already happened before access is denied. Prefer@PreAuthorizefor those.
@Secured
@Secured is the older, simpler annotation. It takes literal authority strings (with the ROLE_ prefix) and supports no SpEL.
import org.springframework.security.access.annotation.Secured;
@Secured("ROLE_ADMIN")
public void purge() { ... }
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void approve(Long id) { ... }
| Annotation | When it runs | SpEL? | Argument/return access |
|---|---|---|---|
@PreAuthorize | Before method | Yes | #arg, authentication |
@PostAuthorize | After method | Yes | returnObject, #arg |
@Secured | Before method | No | none |
Prefer @PreAuthorize for almost everything — it is more expressive and is the documented modern default.
SpEL building blocks
Inside these expressions you have access to a rich set of security functions and variables:
hasRole('ADMIN') // ROLE_ADMIN (prefix added)
hasAnyRole('USER', 'ADMIN')
hasAuthority('orders:read') // verbatim, no prefix
isAuthenticated()
authentication // the Authentication object
authentication.name // current username
authentication.principal // the UserDetails
principal.id // your custom field
#id, #form.ownerId // method arguments by name
@beanName.check(#id) // call a Spring bean method
A common pattern delegates a complex decision to a dedicated bean for readability and reuse:
@PreAuthorize("@accountSecurity.canEdit(#id, authentication)")
public void update(Long id, AccountForm form) { ... }
@Component("accountSecurity")
public class AccountSecurity {
public boolean canEdit(Long id, Authentication auth) {
// arbitrary logic, e.g. check ownership in the DB
return true;
}
}
What a denial looks like
A failed check throws AccessDeniedException, which Spring maps to 403 for web requests:
HTTP/1.1 403 Forbidden
{"status":403,"error":"Forbidden"}
Pitfalls
- Self-invocation — calling an annotated method from another method in the same bean bypasses the proxy, so the check does not run. Call through the bean reference or split the methods across beans.
@PostAuthorizeside effects — the method already executed before denial; avoid for writes.- Forgetting
@EnableMethodSecurity— annotations are silently ignored without it. securedEnabledis off by default — enable it if you use@Secured.