Spring Method Security: @PreAuthorize and @PostAuthorize
Introduction
This tutorial moves security rules from URLs to the methods of your services, with Spring Security's method security. We will see when URL rules are not enough, switch method security on with@EnableMethodSecurity, and protect a document service with @PreAuthorize, using roles, method parameters and a bean of our own in the expressions. We will check results with @PostAuthorize, filter lists with @PostFilter, and compare the older @Secured. A program then calls the service as four different users and prints what each call returns or why it was refused.Why method security
URL rules, from Configuring SecurityFilterChainClick to read tutorial: Configuring SecurityFilterChain in Spring Security, decide by address: "only editors may POST to/api/books". Many real rules depend on the data: "a user may read their own documents", "an editor may change any document except a confidential one". The URL /documents/3 does not say who owns document 3. Method security puts the rule on the service method, where the arguments and the result are known, and it protects the method however it is called - from a controller, a scheduled job or a message listener.@EnableMethodSecurity on a configuration class switches it on. Like @Transactional in the transactions tutorialClick to read tutorial: Spring @Transactional: Propagation and Isolation, it works through a proxy around the bean, which checks the rules before and after each call -
The example
A document has an owner and may be confidential. Three documents are kept in memory: Ann's notes, Raj's confidential salary review, and Raj's team handbook -Document.java - a document with an owner
//Document.java
package com.javabrahman.security.methods;
public record Document(long id, String owner, String title, boolean confidential) {
}
DocumentPolicy.java - a bean that security expressions can call
//DocumentPolicy.java
package com.javabrahman.security.methods;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component("documentPolicy")
public class DocumentPolicy {
private final DocumentStore store;
public DocumentPolicy(DocumentStore store) {
this.store = store;
}
// the owner may edit, and so may an editor - but nobody may edit a confidential document except its owner
public boolean canEdit(long id, Authentication user) {
Document doc = store.get(id);
boolean editor = user.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_EDITOR"));
return doc.owner().equals(user.getName()) || (editor && !doc.confidential());
}
}
DocumentStore.java - three documents in memory
//DocumentStore.java
package com.javabrahman.security.methods;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.stereotype.Component;
@Component
public class DocumentStore {
private final Map<Long, Document> docs = new TreeMap<>(Map.of(
1L, new Document(1, "ann", "Ann's notes", false),
2L, new Document(2, "raj", "Raj's salary review", true),
3L, new Document(3, "raj", "Team handbook", false)));
public Document get(long id) {
return docs.get(id);
}
public List<Document> all() {
return new ArrayList<>(docs.values());
}
public void put(Document doc) {
docs.put(doc.id(), doc);
}
}
authentication is the current user, #owner and #id are method parameters by name, returnObject is the result, filterObject each element of a returned collection, and @documentPolicy a bean by its name. Functions such as hasRole(), hasAuthority() and isAuthenticated() are the same as in URL rules -
DocumentService.java - the rules, on the methods themselves
//DocumentService.java
package com.javabrahman.security.methods;
import java.util.List;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class DocumentService {
private final DocumentStore store;
public DocumentService(DocumentStore store) {
this.store = store;
}
@PreAuthorize("isAuthenticated()")
public int count() {
return store.all().size();
}
@PreAuthorize("#owner == authentication.name") // a method parameter in the expression
public List<Document> byOwner(String owner) {
return store.all().stream().filter(d -> d.owner().equals(owner)).toList();
}
@PostAuthorize("returnObject.owner == authentication.name or hasRole('ADMIN')") // checks the result
public Document find(long id) {
System.out.println(" (find(" + id + ") ran)");
return store.get(id);
}
@PostFilter("!filterObject.confidential or filterObject.owner == authentication.name")
public List<Document> all() { // removes elements
return store.all();
}
@PreAuthorize("@documentPolicy.canEdit(#id, authentication)") // calls a bean
public Document rename(long id, String title) {
Document old = store.get(id);
Document renamed = new Document(id, old.owner(), title, old.confidential());
store.put(renamed);
return renamed;
}
@Secured("ROLE_ADMIN") // the older annotation
public void purge() {
System.out.println(" (purge ran)");
}
}
@EnableMethodSecurity(securedEnabled = true) switches on the pre- and post- annotations, which are on by default, and @Secured, which is not. To act as a user, the program puts an authenticated token into the SecurityContextHolder itself - the same thing a login filter does in a web application, and what test support such as @WithMockUser does in tests -
MethodSecurityApp.java - calling the service as different users
//MethodSecurityApp.java
package com.javabrahman.security.methods;
import java.util.List;
import java.util.function.Supplier;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
@SpringBootApplication
@EnableMethodSecurity(securedEnabled = true)
public class MethodSecurityApp {
@Bean
CommandLineRunner demo(DocumentService docs) {
return args -> {
System.out.println("== nobody logged in");
call("count()", docs::count);
as("ann", "ROLE_USER");
System.out.println("== ann (USER)");
call("count()", docs::count);
call("byOwner(\"ann\")", () -> titles(docs.byOwner("ann")));
call("byOwner(\"raj\")", () -> titles(docs.byOwner("raj")));
call("find(1)", () -> docs.find(1).title());
call("find(3)", () -> docs.find(3).title());
call("all()", () -> titles(docs.all()));
call("rename(3, ...)", () -> docs.rename(3, "Handbook v2").title());
call("purge()", () -> { docs.purge(); return "done"; });
as("eve", "ROLE_USER", "ROLE_EDITOR");
System.out.println("== eve (USER, EDITOR)");
call("rename(3, ...)", () -> docs.rename(3, "Handbook v2").title());
call("rename(2, ...)", () -> docs.rename(2, "Salaries").title());
as("max", "ROLE_USER", "ROLE_ADMIN");
System.out.println("== max (USER, ADMIN)");
call("find(3)", () -> docs.find(3).title());
call("all()", () -> titles(docs.all()));
call("purge()", () -> { docs.purge(); return "done"; });
};
}
private static void as(String name, String... roles) {
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(name, null, AuthorityUtils.createAuthorityList(roles)));
}
private static void call(String what, Supplier<Object> work) {
try {
System.out.println(what + " -> " + work.get());
} catch (RuntimeException e) {
System.out.println(what + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private static List<String> titles(List<Document> list) {
return list.stream().map(Document::title).toList();
}
public static void main(String[] args) {
SpringApplication.run(MethodSecurityApp.class, args);
}
}
== nobody logged in
count() -> AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext
== ann (USER)
count() -> 3
byOwner("ann") -> [Ann's notes]
byOwner("raj") -> AuthorizationDeniedException: Access Denied
(find(1) ran)
find(1) -> Ann's notes
(find(3) ran)
find(3) -> AuthorizationDeniedException: Access Denied
all() -> [Ann's notes, Team handbook]
rename(3, ...) -> AuthorizationDeniedException: Access Denied
purge() -> AuthorizationDeniedException: Access Denied
== eve (USER, EDITOR)
rename(3, ...) -> Handbook v2
rename(2, ...) -> AuthorizationDeniedException: Access Denied
== max (USER, ADMIN)
(find(3) ran)
find(3) -> Handbook v2
all() -> [Ann's notes, Handbook v2]
(purge ran)
purge() -> done
Explanation of the output: @PreAuthorize
- Nobody logged in:
count()failed withAuthenticationCredentialsNotFoundException, "An Authentication object was not found in the SecurityContext". Outside a web request there is no anonymous user, unless something puts one there. - Ann may count (any authenticated user may), and
byOwner("ann")returned her notes.byOwner("raj")was refused withAuthorizationDeniedException: Access Denied: the expression#owner == authentication.namecompared the argument "raj" with her name. Parameter names in expressions need the class compiled with-parameters, which Spring Boot's Maven and Gradle plugins configure;@P("owner")on the parameter names it explicitly otherwise. - The custom bean:
rename(3, ...)calleddocumentPolicy.canEdit(3, authentication). Ann neither owns document 3 nor is an editor: refused. Eve is an editor, and document 3 is not confidential: allowed, the title became "Handbook v2". Document 2 is confidential, so even Eve was refused. A rule this specific is much clearer as Java code in a bean than as one long expression, and the bean can be unit-tested on its own. @Secured("ROLE_ADMIN")onpurge()refused Ann and allowed Max, the admin. It accepts only role and authority names, no expressions;@PreAuthorize("hasRole('ADMIN')")does the same and more, and is the usual choice. The JSR-250 annotations,@RolesAllowedand@PermitAll, are available too withjsr250Enabled = true.
Explanation of the output: @PostAuthorize and @PostFilter
find(1)as Ann: the method ran - "(find(1) ran)" - and the result, her own document, passedreturnObject.owner == authentication.name.find(3)as Ann: the method also ran, and only then was its result refused, because document 3 belongs to Raj. Who owns a document can only be known after loading it, which is what@PostAuthorizeis for. But as the diagram warns, anything the method did - writing an audit row, sending a message - has already happened. Use it on read-only methods.find(3)as Max passed throughor hasRole('ADMIN').all()does not refuse;@PostFilterremoves the elements for which the expression is false. Ann saw "Ann's notes" and "Team handbook", not the confidential review. So did Max: the filter lets only the owner see a confidential document, and has no exception for admins. Rules mean exactly what they say, which is a good reason to test them.@PostFilterfilters in memory after loading everything, so for large tables filter in the query instead.
Method security in practice
- Use both layers: URL rules for broad areas ("/admin needs ADMIN"), method rules for decisions that depend on data. A forgotten URL rule is then still covered by the method rule.
- In a web application, an
AuthorizationDeniedExceptionthrown from a controller's call is turned into a 403 by Spring Security'sExceptionTranslationFilter, like a refused URL rule. - The proxy rules apply: annotations on
privatemethods, or on methods called throughthisfrom the same class, are not checked. - Meta-annotations keep expressions in one place: annotate your own
@IsDocumentOwnerannotation with the@PreAuthorize, and use it on the methods. - Test the rules, for example with
@WithMockUser(roles = "EDITOR")fromspring-security-test, covered in the testing module of this series.
Summary
In this tutorial we protected service methods instead of URLs.@EnableMethodSecurity wraps beans in a proxy that evaluates SpEL expressions before and after each call. @PreAuthorize refused calls before they ran - by authentication, by comparing a parameter with the user's name, and through a policy bean called from the expression. @PostAuthorize checked the result after the method had run, which the output made visible, and @PostFilter removed elements from a returned list. @Secured offered a simpler role check. Calls without any authentication failed with AuthenticationCredentialsNotFoundException, and refused calls with AuthorizationDeniedException. The next tutorial explains three protections that work in the browser: CSRF, CORS and security headersClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained.Spring Security Tutorials
Spring Security OAuth2 Resource Server with JWTClick to read tutorial: Spring Security OAuth2 Resource Server with JWT Spring Security CSRF, CORS and Security Headers ExplainedClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained Spring Security Architecture: Filter Chain ExplainedClick to read tutorial: Spring Security Architecture: Filter Chain Explained Configuring SecurityFilterChain in Spring SecurityClick to read tutorial: Configuring SecurityFilterChain in Spring Security Spring Security Users, Passwords and PasswordEncoderClick to read tutorial: Spring Security Users, Passwords and PasswordEncoder Spring Security Form Login and Session AuthenticationClick to read tutorial: Spring Security Form Login and Session Authentication Stateless REST API Security with JWT in Spring BootClick to read tutorial: Stateless REST API Security with JWT in Spring Boot
Spring Security OAuth2 Resource Server with JWTClick to read tutorial: Spring Security OAuth2 Resource Server with JWT Spring Security CSRF, CORS and Security Headers ExplainedClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained Spring Security Architecture: Filter Chain ExplainedClick to read tutorial: Spring Security Architecture: Filter Chain Explained Configuring SecurityFilterChain in Spring SecurityClick to read tutorial: Configuring SecurityFilterChain in Spring Security Spring Security Users, Passwords and PasswordEncoderClick to read tutorial: Spring Security Users, Passwords and PasswordEncoder Spring Security Form Login and Session AuthenticationClick to read tutorial: Spring Security Form Login and Session Authentication Stateless REST API Security with JWT in Spring BootClick to read tutorial: Stateless REST API Security with JWT in Spring Boot