Method Security with @PreAuthorize
Enable method security and guard service methods with @PreAuthorize and @PostAuthorize expressions that can see method arguments and return values.
@EnableMethodSecurity // on a @Configuration class
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("#email == authentication.name")
@PostAuthorize("returnObject.ownerEmail == authentication.name")URL rules cannot see what a request is about. They know the path is /api/projects/7, but not who owns project 7. Method security moves the check onto the Java method, where the arguments and the return value are available. Switch it on once with @EnableMethodSecurity on a configuration class.
@PreAuthorize("hasRole('ADMIN') or #ownerEmail == authentication.name")
public List<Project> projectsOf(String ownerEmail) { ... }The annotations
@PreAuthorizeruns a SpEL expression before the method. If it is false, the method never runs.@PostAuthorizeruns after the method and can inspectreturnObject. Useful when you only know the owner after loading the record, but the method has already run, so never use it on methods that change data.@PreFilterand@PostFilterremove elements from a collection argument or result. They filter in memory, so a query that loads ten thousand rows to return three is still slow; filter in SQL when you can.
Expressions can use hasRole, hasAuthority, authentication, method parameters as #name, and other beans as @beanName. Parameter names come from the compiled class, which works because Spring Boot's build plugins compile with -parameters. A build that drops that flag turns #id into null and every check fails.
Keep the logic in Java
Long expressions are hard to read and impossible to unit test. Once a check needs more than one condition, move it into a small bean and call it: @PreAuthorize("@projectAccess.canEdit(#id, authentication)"). The bean is plain Java, testable on its own, and can query the database.
Proxy rules apply
Method security works through the same kind of proxy as @Transactional. A call from one method to another in the same class skips the check, and private methods are never checked. Put the annotation on the public service method that controllers call.
What the client sees
A failed check throws AuthorizationDeniedException, a subclass of AccessDeniedException, which Spring Security's ExceptionTranslationFilter turns into 403 (or 401 when the caller is anonymous). The trap is a @RestControllerAdvice with a catch-all @ExceptionHandler(Exception.class): it catches the exception first and answers 500. Add an explicit handler for AccessDeniedException that returns 403.
Annotate service methods rather than controllers when the same operation is reachable from several places, such as a REST endpoint, a scheduled job and a message listener. Remember that jobs and listeners run with no authenticated user, so a guarded method called from them is denied unless you set up a security context for that call.
Example
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
class MethodSecurityConfig {
}
record Project(long id, String ownerEmail, String name) {
}
interface ProjectStore {
Optional<Project> findById(long id);
List<Project> findByOwner(String ownerEmail);
Project rename(long id, String name);
}
("projectAccess")
class ProjectAccess {
private final ProjectStore store;
ProjectAccess(ProjectStore store) {
this.store = store;
}
public boolean canEdit(long projectId, Authentication auth) {
return store.findById(projectId)
.map(p -> p.ownerEmail().equals(auth.getName()))
.orElse(false);
}
}
public class ProjectService {
private final ProjectStore store;
ProjectService(ProjectStore store) {
this.store = store;
}
("hasRole('ADMIN') or #ownerEmail == authentication.name")
public List<Project> projectsOf(String ownerEmail) {
return store.findByOwner(ownerEmail);
}
("hasRole('SUPPORT') or returnObject.ownerEmail() == authentication.name")
public Project get(long id) {
return store.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
("@projectAccess.canEdit(#id, authentication)")
public Project rename(long id, String name) {
return store.rename(id, name);
}
}
When to use it
- Let customers edit only their own projects while support staff can read every project.
- Protect a refund operation that is called from a REST endpoint and from an admin batch job.
- Replace scattered if-checks on the current user with one tested access bean referenced from annotations.
More examples
Answering 403, not 500
Exception handlers are chosen by the closest matching type, so the <code>AccessDeniedException</code> handler wins over the catch-all for denied method calls. Without it, every failed <code>@PreAuthorize</code> check is logged as an unexpected error and reported as <code>500</code>, which sets off alerts and confuses clients that retry on server errors. One side effect: the handler answers <code>403</code> for anonymous callers too, where Spring Security would have sent <code>401</code>, so keep <code>authenticated()</code> on the URL so that anonymous requests are stopped before they reach the method.
import java.net.URI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(AccessDeniedException.class)
ProblemDetail accessDenied(AccessDeniedException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "You do not have access to this resource.");
problem.setType(URI.create("https://example.com/problems/forbidden"));
return problem;
}
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
log.error("Unhandled error", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong.");
}
}
A reusable meta-annotation with a template
Repeating the same expression string across dozens of methods invites typos that silently weaken a check. A meta-annotation names the rule once. With an <code>AnnotationTemplateExpressionDefaults</code> bean, placeholders such as <code>{roles}</code> are replaced from the annotation's attributes, so one annotation serves several role combinations. The bean method is <code>static</code> so it is available before the method security infrastructure is built.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults;
import org.springframework.stereotype.Service;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole({roles})")
@interface RequireRoles {
String[] roles();
}
@Configuration
@EnableMethodSecurity
class TemplateSecurityConfig {
@Bean
static AnnotationTemplateExpressionDefaults templateDefaults() {
// Lets {roles} in the meta-annotation be filled from the attribute.
return new AnnotationTemplateExpressionDefaults();
}
}
@Service
public class BillingService {
@RequireRoles(roles = {"'BILLING'", "'ADMIN'"})
public void issueCreditNote(long invoiceId, long amountPence) {
// only billing staff and admins reach this line
}
}
Filtering a returned list
<code>@PostFilter</code> evaluates the expression for each element, with the element available as <code>filterObject</code>, and removes those that fail. The collection must be mutable, so returning <code>List.of(...)</code> or a <code>toList()</code> result throws at runtime. Use it for small lists; for large tables put the ownership condition in the query, or you load and discard most of the rows on every request.
import java.util.ArrayList;
import java.util.List;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.stereotype.Service;
record Document(long id, String ownerEmail, boolean shared, String title) {
}
@Service
public class DocumentService {
private final List<Document> recent = new ArrayList<>();
// Each element is kept only if the expression is true for it.
@PostFilter("filterObject.shared() or filterObject.ownerEmail() == authentication.name")
public List<Document> recentDocuments() {
// Must return a mutable collection: the filter removes elements in place.
return new ArrayList<>(recent);
}
}
Discussion