The first time you write @PreAuthorize("hasPermission(#id, 'Document', 'read')") and add a PermissionEvaluator bean, you expect it to work. In Spring Security 7.1.2 it does not: every call is denied, for every user, and the only hint is a WARN log line. This post builds the working version, shows the deny-all setup next to it, and then runs the cases that bite in real code: @PostAuthorize side effects, a forged object, N+1 loads in @PostFilter, and a cached decision that outlives a change of owner.
Versions. Spring Security 7.1.2 (GA), Spring Boot 4.1.2, JDK 25, JUnit Jupiter 6. All outputs below were produced bymvn teston exactly these versions. About 7.2: a planning note I started from said 7.2 changeshasPermission(). I comparedjavap -psignatures ofPermissionEvaluator,DefaultMethodSecurityExpressionHandler,DenyAllPermissionEvaluatorandPreAuthorizeAuthorizationManagerin spring-security-core 7.1.2 against 7.2.0-M2 and 7.2.0-RC1: they are identical. 7.2.0 had no GA release when this was written, and everything here is the 7.1.2 behaviour. Signatures say nothing about behaviour inside method bodies, so re-run the tests when 7.2 ships.
The code is one module of the companion repository: spring-auth-demo/permission-evaluator. Every code block and every line of output below is a real file in it, linked under the block. The explanations stay in this post; the repository has only code and captured output.
The model: documents, owners and teams
A Document has an id, an owner, a team and a title. Alice owns documents 1 and 2 (team-a), Carol owns document 3 (team-b), Bob owns document 4 (team-a). The rules: an admin can do anything, the owner can read, write and delete, and anyone on the document’s team can read.
package com.ankurm.permission.domain;
/**
* The object we authorize against. {@code owner} is a username; {@code team} is the team
* whose members may read the document.
*/
public record Document(long id, String owner, String team, String title) {
public Document withOwner(String newOwner) {
return new Document(id, newOwner, team, title);
}
public Document withTitle(String newTitle) {
return new Document(id, owner, team, newTitle);
}
}
Source: Document.java. The in-memory table behind it is DocumentRepository.java; it counts every findById so later sections can report how many loads a check costs.
The evaluator: two methods, two ways to be called
PermissionEvaluator has two methods. Spring picks one by the shape of the expression: hasPermission(obj, 'read') calls the object form, hasPermission(id, 'Document', 'read') calls the id form. The id form is where you load the object yourself. Both forms deny on anything unexpected: an unknown type, a missing document, an unknown permission name.
package com.ankurm.permission.security;
import java.io.Serializable;
import com.ankurm.permission.domain.Document;
import com.ankurm.permission.domain.DocumentRepository;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
/**
* Decides whether the caller may {@code read}, {@code write} or {@code delete} a Document.
*
* <p>Rules: an admin may do anything; the owner may read, write and delete; members of the
* document's team may read.
*
* <p>Spring calls one of two methods depending on how the expression was written:
* <ul>
* <li>{@code hasPermission(returnObject, 'read')} calls the object overload (the Document
* is already in memory);</li>
* <li>{@code hasPermission(#id, 'Document', 'read')} calls the id overload, so this class
* has to load the Document itself.</li>
* </ul>
*/
@Component
public class DocumentPermissionEvaluator implements PermissionEvaluator {
private final DocumentRepository documents;
private final UserDirectory users;
public DocumentPermissionEvaluator(DocumentRepository documents, UserDirectory users) {
this.documents = documents;
this.users = users;
}
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
if (!(targetDomainObject instanceof Document document)) {
return false; // unknown target type: deny
}
return decide(authentication, document, String.valueOf(permission));
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType,
Object permission) {
if (!"Document".equals(targetType) || !(targetId instanceof Number id)) {
return false; // unknown type or id shape: deny
}
return documents.findById(id.longValue())
.map(document -> decide(authentication, document, String.valueOf(permission)))
.orElse(false); // a missing document is denied, not "not found"
}
private boolean decide(Authentication authentication, Document document, String permission) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
boolean admin = authentication.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
if (admin) {
return true;
}
String user = authentication.getName();
boolean owner = document.owner().equals(user);
boolean sameTeam = users.teamOf(user).equals(document.team());
return switch (permission) {
case "read" -> owner || sameTeam;
case "write", "delete" -> owner;
default -> false; // unknown permission name: deny
};
}
}
Source: DocumentPermissionEvaluator.java. Team lookup is UserDirectory.java.
Going deeper
- Design choice: a missing document returns
false, not an exception, so the caller gets a 403 and cannot tell “does not exist” from “not yours”. The HTTP section below shows what that looks like. - The evaluator is a plain
@Component. That alone is not enough, as the next section shows.
The trap: the bean alone is ignored
With @EnableMethodSecurity and the evaluator registered as a bean, nothing else configured, I ran every user against document 1 for read, write and delete. The test first records which beans exist, then the matrix.
PermissionEvaluator beans: [documentPermissionEvaluator]
MethodSecurityExpressionHandler beans: []
alice read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
alice write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
alice delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
Output: 01-bean-only-wiring.txt. The context has one PermissionEvaluator and no MethodSecurityExpressionHandler, and all twelve calls are denied, admin included. The log says who did the denying:
o.s.s.a.e.DenyAllPermissionEvaluator : Denying user alice permission 'read' on object with Id 1
o.s.s.a.e.DenyAllPermissionEvaluator : Denying user alice permission 'write' on object with Id 1
o.s.s.a.e.DenyAllPermissionEvaluator : Denying user alice permission 'delete' on object with Id 1
Output: 00-denyall-log.txt. These are the first three of the twelve warnings. The evaluator doing the work is Spring Security’s DenyAllPermissionEvaluator, not yours. Your evaluator’s code never ran. If you see Denying user ... permission ... on object with Id in your log, this is the cause.
The fix: give the handler your evaluator
Expose a MethodSecurityExpressionHandler bean with your evaluator set on it. In the demo this lives in a profile named handler so the same module can show both setups.
package com.ankurm.permission.security;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
/**
* Profile {@code handler}: wire the evaluator into method security explicitly.
* Without this profile only the {@link PermissionEvaluator} bean exists. The tests record
* what each setup actually does.
*
* <p>The bean method is {@code static} so the handler is created early, before the
* configuration class that declares it.
*/
@Configuration
@Profile("handler")
public class ExplicitHandlerConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(PermissionEvaluator evaluator,
ApplicationContext context) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(evaluator);
handler.setApplicationContext(context);
return handler;
}
}
Source: ExplicitHandlerConfig.java. The bean method is static, which is the usual convention for infrastructure beans that other configuration depends on. I did not test the non-static variant. The security configuration that enables method security and defines the four users is SecurityConfig.java.
The same matrix with that bean in place:
alice read doc 1 -> ALLOWED
alice write doc 1 -> ALLOWED
alice delete doc 1 -> ALLOWED
bob read doc 1 -> ALLOWED
bob write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin read doc 1 -> ALLOWED
admin write doc 1 -> ALLOWED
admin delete doc 1 -> ALLOWED
Output: 02-explicit-handler-matrix.txt. Alice (owner) is allowed everything, Bob (same team) may only read, Carol (other team) gets nothing, and admin gets everything. Denials surface as AuthorizationDeniedException, a subclass of AccessDeniedException, so existing handlers keep working.
Going deeper
- Why not just write
hasRole-style SpEL? Roles answer “what kind of user is this”.hasPermissionanswers “may this user touch this particular object”, which needs the object.
Using it: object or id, before or after
The service has one method per style so you can compare them side by side. In short: @PreAuthorize with an id checks before the body runs and costs one load; @PostAuthorize with returnObject checks after, with no extra load; @PostFilter checks each list element.
Show the whole DocumentService (every way of calling hasPermission)
package com.ankurm.permission.service;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.concurrent.atomic.AtomicInteger;
import com.ankurm.permission.domain.Document;
import com.ankurm.permission.domain.DocumentRepository;
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;
/**
* Every method here carries a different way of calling {@code hasPermission(...)}.
* The comment above each one says what the check costs and where it can go wrong.
*/
@Service
public class DocumentService {
private final DocumentRepository repository;
private final AtomicInteger bodyRuns = new AtomicInteger();
public DocumentService(DocumentRepository repository) {
this.repository = repository;
}
/** Id overload: the evaluator loads the Document, then this method loads it again (2 loads). */
@PreAuthorize("hasPermission(#id, 'Document', 'read')")
public Document get(long id) {
return repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
}
/**
* Object overload on the return value. The method body runs BEFORE the check, so any side
* effect in it (here: {@code bodyRuns}) happens even when access is then denied.
*/
@PostAuthorize("hasPermission(returnObject, 'read')")
public Document getThenCheck(long id) {
bodyRuns.incrementAndGet();
return repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
}
/**
* Object overload on an argument. The caller supplies the Document, so the caller also
* supplies its {@code owner} field. Do not expose this to untrusted input.
*/
@PreAuthorize("hasPermission(#doc, 'write')")
public Document update(Document doc) {
return repository.save(doc);
}
/** Safe variant: only the id comes from the caller; the evaluator loads the real owner. */
@PreAuthorize("hasPermission(#id, 'Document', 'write')")
public Document updateTitle(long id, String title) {
Document current = repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
return repository.save(current.withTitle(title));
}
@PreAuthorize("hasPermission(#id, 'Document', 'delete')")
public void delete(long id) {
repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
}
/** Object overload per element: no extra loads, the objects are already in memory. */
@PostFilter("hasPermission(filterObject, 'read')")
public List<Document> list() {
return repository.findAll();
}
/** Id overload per element: one extra load per element, so N+1 loads in total. */
@PostFilter("hasPermission(filterObject.id(), 'Document', 'read')")
public List<Document> listByIdCheck() {
return repository.findAll();
}
public int bodyRuns() {
return bodyRuns.get();
}
}
Source: DocumentService.java.
@PostAuthorize runs the method first
A denial after the fact does not undo what the body did. The service method getThenCheck increments a counter in its body; Carol calls it for a document she may not read.
carol getThenCheck(1) -> DENIED (AuthorizationDeniedException: Access Denied)
method body ran 1 time(s) during that call
Output: 03-postauthorize-side-effect.txt. Carol is denied and the body still ran once. For a read that is harmless. For a method that sends an email or writes a row, use @PreAuthorize, or check before doing the work.
A forged object passes the object check
hasPermission(#doc, 'write') trusts the Document the caller passes in, including its owner field. Carol sends a document that says she is the owner of document 1, then tries the id-only variant.
document 1 before: Document[id=1, owner=alice, team=team-a, title=Q3 plan]
carol update(forged owner=carol) -> ALLOWED
document 1 after: Document[id=1, owner=carol, team=team-b, title=hijacked]
carol updateTitle(1, "hijacked") -> DENIED (AuthorizationDeniedException: Access Denied)
document 1 after: Document[id=1, owner=alice, team=team-a, title=Q3 plan]
Output: 04-forged-owner.txt. The forged update is allowed and document 1 now belongs to Carol. The id-only updateTitle loads the stored document, sees Alice as owner, and denies. Use the object form only on objects you loaded yourself, never on one bound from a request body.
Going deeper
- Rule of thumb: arguments from outside get the id form; return values and elements you loaded get the object form.
What the check costs: loads and caching
The id form loads the document inside the evaluator, so the method that follows usually loads it again. The test counts findById calls for Alice.
get(1) loads: 2
list() loads: 0 (returned 3 of 4)
listByIdCheck() loads: 4 (returned 3 of 4)
Output: 05-load-counts.txt. get(1) costs two loads, one in the evaluator and one in the method. list() with the object form costs none extra. The same list filtered by id costs one load per element, four here, which is the N+1 shape. With a real database that is four more queries.
One fix is to cache the decision. The demo has a caching decorator, active in the cache profile, that memoizes the id form per user, type, id and permission.
Show CachingPermissionEvaluator
package com.ankurm.permission.security;
import java.io.Serializable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
/**
* Profile {@code cache}: memoizes the id-based decision per (user, type, id, permission).
*
* <p>This saves the Document load on repeated checks. The price is staleness: a cached
* "allow" survives a change of ownership until {@link #evictAll()} is called. The tests
* show both halves.
*/
@Component
@Primary
@Profile("cache")
public class CachingPermissionEvaluator implements PermissionEvaluator {
private final DocumentPermissionEvaluator delegate;
private final Map<String, Boolean> decisions = new ConcurrentHashMap<>();
public CachingPermissionEvaluator(DocumentPermissionEvaluator delegate) {
this.delegate = delegate;
}
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
return delegate.hasPermission(authentication, targetDomainObject, permission); // already in memory
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType,
Object permission) {
String key = authentication.getName() + "|" + targetType + "|" + targetId + "|" + permission;
return decisions.computeIfAbsent(key,
k -> delegate.hasPermission(authentication, targetId, targetType, permission));
}
public void evictAll() {
decisions.clear();
}
}
Source: CachingPermissionEvaluator.java.
Three calls to get(1), then a change of owner behind the evaluator’s back:
3 x get(1) with cache: 4 loads (uncached it is 6)
alice updateTitle(1) -> ALLOWED
owner of doc 1 is now bob
alice updateTitle(1) -> ALLOWED (cached allow)
after evictAll() -> DENIED (AuthorizationDeniedException: Access Denied)
Output: 06-cache-and-staleness.txt. Three reads cost four loads instead of six. But after document 1 changes owner to Bob, Alice is still allowed to write it, because the cached allow survived. Only evictAll() brings the correct denial. If you cache decisions, evict on every change that can alter them, or give entries a short lifetime.
Not measured here. The demo counts loads, not milliseconds, and uses an in-memory map instead of a database. The load counts transfer to a real repository; timings do not.
Over HTTP: 401, 403 and a missing document
The controller is a thin wrapper over the service. These are the status codes under HTTP Basic, using MockMvc.
no credentials GET /documents/1 -> 401
alice GET /documents/1 -> 200
carol GET /documents/1 -> 403
alice GET /documents/99 -> 403
admin GET /documents/99 -> 403
bob PUT /documents/1?title=x -> 403
alice PUT /documents/1?title=x -> 200
Output: 07-http-status-codes.txt. No credentials gives 401, Carol on Alice’s document gives 403, and Bob trying to rename it gives 403. Two lines deserve a second look: document 99 does not exist and still returns 403, even for admin, because the id form’s evaluator returns false for a missing document before admin logic runs. That hides existence from outsiders, which is often what you want, but admin cannot get a 404 either. If you need one, check admin before loading, or let the evaluator allow admin on a missing id and let the method raise not-found. Controller: DocumentController.java.
Run it yourself
Clone the repository, go into permission-evaluator and run mvn test on JDK 25. Seven tests run; they write the files under output/ and assert the behaviour shown above (for example, that the bean-only setup allows nothing, and that the cached allow goes stale). Tests: DefaultWiringTest.java, ExplicitHandlerTest.java, CacheTest.java, HttpTest.java; helpers Support.java and Transcript.java. Build file: pom.xml.
Frequently asked questions
Why does hasPermission() always return false in Spring Security 7?
Without a MethodSecurityExpressionHandler bean that carries your PermissionEvaluator, method security uses DenyAllPermissionEvaluator, so every hasPermission() call is denied and your evaluator is never called. In the tested setup (Spring Security 7.1.2, Spring Boot 4.1.2) exposing a handler bean with setPermissionEvaluator fixed it.
Should I pass the object or the id to hasPermission()?
Use the object form for objects you loaded yourself, such as returnObject or filterObject. Use the id plus type form for anything that came from the caller, because the evaluator then loads the real object. The object form trusts whatever fields the caller supplied.
Does Spring Security 7.2 change PermissionEvaluator?
Not at the signature level. javap output for PermissionEvaluator, DefaultMethodSecurityExpressionHandler, DenyAllPermissionEvaluator and PreAuthorizeAuthorizationManager is identical between 7.1.2 and 7.2.0-M2 and 7.2.0-RC1. Behaviour inside methods was not compared.
Conclusion
A PermissionEvaluator bean is not enough: hand it to a MethodSecurityExpressionHandler or every check is denied by the default evaluator. After that, the decisions that matter are the ones around it: check before the body when the body has side effects, pass ids rather than caller-built objects, count the loads your checks cause, and treat any cached decision as stale the moment ownership can change.
See also
- Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation
- Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API
No Comments yet!