Method Security in Spring Security 7: @PreAuthorize, @PostAuthorize and the Proxy Traps
@PreAuthorize is advice on a proxy, and there are three ways a call reaches an annotated method without the check ever running — self-invocation, methods the proxy cannot override, and @PreFilter handed an immutable collection. A verified tour of Spring Security 7.1 method security: the full SpEL reference, filterObject, interceptor ordering against @Transactional, and the tests that catch a check that silently is not there. Every transcript comes from a runnable companion repository.
A method carries @PreAuthorize("hasRole('ADMIN')"). A user without ROLE_ADMIN calls it. The method runs, returns the data, and nothing anywhere logs a thing.
That is not a hypothetical. It is what happens when the call originates inside the same class, and it is what happens when the method is final, and it is what happens when @PreFilter is handed a List.of(…). In all three cases the annotation is present, the bean is proxied, reflection confirms both, and the check does not run. There is no exception, no warning, no metric. The only way to find out is to write a test that asserts the denial — and a test that only covers the happy path passes identically whether or not the annotation is doing anything at all.
This article is about why that happens, and about the surface area of @PreAuthorize, @PostAuthorize, @PreFilter and @PostFilter in Spring Security 7.1. Everything below was compiled and executed against the real jars; every number, transcript and error message is copied out of a file in the companion repository, which regenerates all of it with one script.
Verified against. JDK 25 (Temurin 25.0.4.1+1, LTS) · Spring Boot 4.1.1 · Spring Framework 7.0.9 · Spring Security 7.1.1 (-core, -config, -test) · Spring Data Commons 4.1.1 · H2 2.4.240 · AspectJ Weaver 1.9.25 · JUnit Jupiter 6.0.3 · AssertJ 3.27.7. Versions taken from maven-metadata.xml on Maven Central, not from release announcements — 4.2.0-M1 and 7.2.0-M1 exist as milestones only. Runnable code: ankurm.com/git.app/asmhatre/spring-security-demo.
If you are here because…
Start at
You want to know what these annotations actually do
@PreAuthorize is not a keyword the JVM understands. It is an annotation that a Spring AOP advisor matches, on a proxy that wraps your bean, intercepting calls that arrive from outside that bean. Every trap in this article follows from that sentence, and once you hold it, each of them becomes obvious rather than mysterious.
Nothing switches this on for you. Spring Boot’s security auto-configuration does not enable method security; @EnableMethodSecurity is yours to add. Its attributes, read out of the AnnotationDefault entries in spring-security-config-7.1.1.jar rather than from documentation:
the alternative, ASPECTJ, sidesteps most of this article
offset
0
shifts every security advisor’s order together
Look at rows two and three for a moment. @Secured("ROLE_ADMIN") and @RolesAllowed("ADMIN") compile, read correctly in review, and do absolutely nothing until you switch them on. That is a fourth silent failure, and unlike the others it takes one attribute to fix — but only if you know to look.
There is no order attribute. Several guides list order = Ordered.LOWEST_PRECEDENCE on @EnableMethodSecurity. The real attribute is offset, it defaults to 0, and it moves all six interceptors as a group rather than setting one absolute position. This matters when you get to transactions.
Here is the whole thing working, which is the shortest part of the article because it is the part every other blog already has. A service, all six annotation families switched on, called by a user holding only ROLE_USER:
@Configuration
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
class Config {
@Bean
BankService bankService() {
return new BankService();
}
}
public class BankService {
@PreAuthorize("hasRole('ADMIN')")
public String adminOnly() {
return "the admin console";
}
@PreAuthorize("#owner == authentication.name")
public List<Account> accountsOf(String owner) { ... }
@PostAuthorize("returnObject.owner == authentication.name")
public Account readAccount(long id) { ... }
@PostFilter("filterObject.owner == authentication.name")
public List<Account> allAccounts() { ... }
}
as alice (ROLE_USER)
--------------------
@PreAuthorize hasRole('ADMIN') DENIED -> AuthorizationDeniedException: Access Denied
@PreAuthorize #owner == authentication.name ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
@PreAuthorize #owner == authentication.name DENIED -> AuthorizationDeniedException: Access Denied
@PostAuthorize returnObject.owner == ...name ALLOWED -> Account[1,alice,100]
@PostAuthorize returnObject.owner == ...name DENIED -> AuthorizationDeniedException: Access Denied
@PostFilter filterObject.owner == ...name ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
@Secured("ROLE_ADMIN") DENIED -> AuthorizationDeniedException: Access Denied
@RolesAllowed("ADMIN") DENIED -> AuthorizationDeniedException: Access Denied
@PermitAll ALLOWED -> open payload
@DenyAll DENIED -> AuthorizationDeniedException: Access Denied
with no Authentication at all (SecurityContextHolder cleared)
-------------------------------------------------------------
@PreAuthorize hasRole('ADMIN') DENIED -> AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext
@PermitAll ALLOWED -> open payload
Two things in that transcript are worth keeping. The exception you get for an insufficient identity is AuthorizationDeniedException — not the AccessDeniedException the documentation names, though it is a subclass, so existing handlers still catch it. And the exception you get for no identity is a completely different type from a completely different place: AuthenticationCredentialsNotFoundException. Inside a servlet request ExceptionTranslationFilter turns those into 403 and 401 respectively. Outside one — a scheduled job, a message listener, a test — nothing translates them and they surface raw.
That second case is more common than it sounds, because the Authentication comes from SecurityContextHolder on the calling thread. An @Async method carrying @PreAuthorize does not fail open; it fails with AuthenticationCredentialsNotFoundException, because the pool thread never received the context. Getting the context onto that thread is a separate problem with its own answer — see Spring Security Context Propagation, which is the other half of this pair.
Everything you can write in the expression
The string inside the annotation is SpEL, evaluated against a MethodSecurityExpressionRoot. Here is its whole surface, each row a real annotated method invoked as two different users in Demo4SpelReference.
Predicate
Means
hasRole('ADMIN')
the authority ROLE_ADMIN; the prefix is configurable
hasAnyRole('A','B') / hasAllRoles('A','B')
any of / all of
hasAuthority('report:read')
the authority string verbatim, no prefix
hasAnyAuthority(…) / hasAllAuthorities(…)
as above
isAuthenticated()
authenticated and not anonymous
isFullyAuthenticated()
and not remember-me either
isRememberMe() / isAnonymous()
the two cases it excludes
permitAll / denyAll
constants; also callable as permitAll() / denyAll()
hasPermission(target, permission)
delegates to a PermissionEvaluator bean
hasPermission(id, 'type', permission)
the id/type overload
hasAllRoles and hasAllAuthorities are easy to miss — they sit on SecurityExpressionRoot next to the any variants and rarely appear in examples.
And the references the root exposes:
There is no positional access to arguments. #root.args[0] produces EL1008E: Property or field 'args' cannot be found. Arguments are bound by name — which is a dependency on a compiler flag, and the first place where “the annotation is there and the rule is not the rule you wrote” shows up.
The -parameters flag
@PreAuthorize("#owner == authentication.name") resolves #owner through a ParameterNameDiscoverer. Java keeps parameter names in the class file only when javac is given -parameters. Without it the name is arg0, #owner resolves to nothing, and the comparison is false. The companion repository compiles the same class twice to make this a diff rather than a claim:
WITH -parameters WITHOUT -parameters
compiled with -parameters : true compiled with -parameters : false
byParameterName param[0] : owner byParameterName param[0] : arg0
alice calling with her own name alice calling with her own name
#owner == authentication.name ALLOWED #owner == authentication.name DENIED
#o == ... (@P("o")) ALLOWED #o == ... (@P("o")) ALLOWED
It fails closed, which is the right direction, but it fails silently in the sense that matters: the rule being enforced is not the rule in the source. Spring Boot sets it for you on both build systems, and it is worth knowing exactly where from: spring-boot-starter-parent sets <parameters>true</parameters> on the compiler plugin, and the Gradle plugin adds -parameters to every JavaCompile task. Note which half that puts where — a Maven project that imports the BOM rather than inheriting the parent does not get it, and neither does a hand-rolled build or a module compiled by a different toolchain. @P("alias") from org.springframework.security.core.parameters is immune, because the name lives in the annotation.
One reflection call answers it for your build.SomeService.class.getMethod("byOwner", String.class).getParameters()[0].isNamePresent(). If that is false, every #parameterName expression in the application is comparing against nothing. Worth an assertion in an architecture test.
Where role prefix and hierarchy moved in 7.1
If you have configured a RoleHierarchy before, the way you did it is now deprecated. AbstractSecurityExpressionHandler.setRoleHierarchy(…) carries @Deprecated in Spring Security 7.1 — the compiler said so while this companion repository was being written, which is the only reason it is in this article. The current home for both the hierarchy and the prefix is an AuthorizationManagerFactory:
PrePostMethodSecurityConfiguration autowires an AuthorizationManagerFactory and a bare RoleHierarchy bean, both @Autowired(required = false), so the old plain-bean approach still works. The factory is the one that is not deprecated, and it is where the prefix and the hierarchy finally live in the same place. With the hierarchy above, a user holding only ROLE_ADMIN passes hasRole('GUEST') — verified, not assumed.
The other expression worth knowing is the bean reference, because it is the escape hatch from the whole problem of putting logic in a string:
@PreAuthorize("@accountPolicy.canRead(authentication, #id)")
public Account read(long id) { ... }
That method is unit-testable, refactorable, and shows up in “find usages”. A rule complicated enough to need T(…) type references or nested ternaries is a rule that wants to be a bean method.
The call that never touches the proxy
Now the first of the two failures this article is named for. The code:
@PreAuthorize("hasRole('ADMIN')")
public String adminReport() {
return "TOP SECRET REVENUE NUMBERS";
}
public String userEntryPoint() {
return adminReport(); // no proxy involved
}
What makes this survive code review is the next block of the same run:
ReportService.adminReport() @PreAuthorize -> @PreAuthorize("hasRole('ADMIN')")
bean is an AOP proxy -> true
proxy class -> ...ReportService$$SpringCGLIB$$0
target class -> ...ReportService
The annotation is present. The bean is proxied. Reflection agrees with the source. Every individual check a reviewer would run comes back green; only the composition is wrong. And because userEntryPoint() returns the correct answer, just to the wrong person, no test that asserts on the value will notice.
The fingerprint. A public method with no annotation, calling an annotated method on the same class. Grep will not find it reliably, but an IDE “find usages” on each annotated method, filtered to its own file, will — and that search takes about a minute per service class.
Three fixes, in the order you should prefer them.
Move the method to a different bean. A call between two beans is external by definition. This is the dull answer and it is usually the right one, because a method that deserves its own authorization rule usually deserves its own class.
Inject the bean into itself. Not as a field of its own type — that is a circular reference the container refuses in a constructor — but through an ObjectProvider, which resolves lazily at call time and hands back the proxy:
private final ObjectProvider<ReportService> self;
public String viaSelfInjection() {
return this.self.getObject().adminReport();
}
AopContext.currentProxy(). Works, needs @EnableAspectJAutoProxy(exposeProxy = true), and throws IllegalStateException: Cannot find current proxy without it. It also hard-codes into the method the fact that it is proxied, which is why it is last:
public String viaAopContext() {
return ((ReportService) AopContext.currentProxy()).adminReport();
}
All three produce AuthorizationDeniedException in the same run. There is a fourth, structural answer: @EnableMethodSecurity(mode = AdviceMode.ASPECTJ) weaves the advice into the bytecode instead of wrapping the object, and self-invocation stops existing as a category. It also means AspectJ weaving in your build, which is a lot of machinery for one class of bug — worth it only if you are already weaving.
The cheapest mitigation is not a fix at all: keep a catch-all anyRequest().authenticated() in authorizeHttpRequests, so a method that escapes its own check is still behind a request-level one. Method security should be your second line, never your only one.
Methods the proxy cannot reach
The second named failure. A CGLIB proxy is a generated subclass; it intercepts a method by overriding it. Anything that cannot be overridden cannot be advised. That much is well known. What is not well known is exactly which modifiers land on which side of the line, and how loudly each one fails. Same class, same @PreAuthorize("hasRole('ADMIN')") on every method, same ROLE_USER caller:
public (overridable) DENIED -> AuthorizationDeniedException
public final (NOT overridable) ALLOWED -> final payload
static (NOT overridable) ALLOWED -> static payload
package-private (overridable, same package) DENIED -> AuthorizationDeniedException
private, reached via a public wrapper ALLOWED -> private payload
What the proxy actually overrode
--------------------------------
publicAdminOnly declared final=false overridden by proxy=true
finalAdminOnly declared final=true overridden by proxy=false
packagePrivateAdminOnly declared final=false overridden by proxy=true
The package-private row is the one that contradicts received wisdom. “Only public methods are secured” is repeated everywhere and is wrong: the generated subclass lands in the same package as the target, so it can and does override a package-private method, and the check runs.
The three unchecked rows are not equally silent. A public final method does get a warning, buried in startup logs:
WARNING CglibAopProxy: Public final method [public final java.lang.String ...Vault.finalAdminOnly()]
cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK proxies.
static and private produce nothing. A finalclass, on the other hand, is the friendliest failure in this whole article, because the container simply refuses to start: IllegalArgumentException: Cannot subclass final class …SealedVault. That is also why a Java record cannot carry method security on its own accessors — records are final — which becomes relevant later.
Everything here applies to @Transactional unchanged. Same proxy, same overriding rules. A private @Transactional method is exactly as inert as a private @PreAuthorize one. If you find one of these in a codebase, go looking for the other — they tend to be written by the same hand on the same afternoon.
There is one more variant, which is loud rather than silent but worth recognising. With the default proxyTargetClass = false, a bean that implements an interface gets a JDK dynamic proxy, and a public annotated method that is not on the interface is not merely unadvised — it is not on the proxy at all:
proxy is a JDK proxy -> true
proxied interfaces -> [interface ...LedgerOperations]
cast proxy to Ledger impl -> ClassCastException: class jdk.proxy2.$Proxy18 cannot be cast to ...Ledger
In a Spring Boot application this is usually moot — Boot’s AopAutoConfiguration defaults spring.aop.proxy-target-class to true, so you get CGLIB everywhere. In a plain Spring context, or with that property flipped, it is live.
The third silent failure: @PreFilter on an immutable collection
@PreFilter and @PostFilter evaluate their expression once per element with the element bound to filterObject, and drop the ones that come back false. The first thing to internalise is that @PreFilter does not hand your method a filtered copy. It clears the collection the caller passed in and adds the survivors back to it:
caller's list before the call : [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
method body saw : [Account[1,alice,150], Account[3,alice,350]]
caller's list after the call : [Account[1,alice,150], Account[3,alice,350]]
The caller’s own list lost an element. If that list was a field, a cache, or shared with anything else, it lost the element there too.
Now pass it something immutable:
method body saw: [Account[1,alice,100], Account[3,alice,300]]
new ArrayList<>(..) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
List.of(..) (immutable) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
List.copyOf(..) (immutable) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
Arrays.asList(..) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
Collections.unmodifiableList(..) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
stream().toList() (unmodifiable since 16) ALLOWED -> (void)
method body saw: [Account[1,alice,150], Account[3,alice,350]]
stream().collect(toList()) (ArrayList) ALLOWED -> (void)
Bob’s account reached the method body in five of those seven cases. No exception, no warning, no 403.
The mechanism is two methods deep and worth reading, because once you have seen it you will never mistake it for anything else. First, DefaultMethodSecurityExpressionHandler.filterCollection:
try {
filterTarget.clear();
filterTarget.addAll(retain);
return filterTarget;
}
catch (UnsupportedOperationException readonly) {
this.logger.trace("Collection threw exception: ... Will return a new instance instead of mutating its state.");
return retain;
}
It computes the correct answer, discovers it cannot write it back, and returns a fresh list instead. Reasonable. Then PreFilterAuthorizationMethodInterceptor.invoke:
The return value of filter(…) is never assigned to anything. @PreFilter depends entirely on in-place mutation succeeding; when it does not, the filter is a no-op and the unfiltered elements go straight into the method body. The only trace is at TRACE level, and it reads like this:
TRACE Retaining elements: [Account[1,alice,100], Account[3,alice,300]]
TRACE Collection threw exception: null. Will return a new instance instead of mutating its state.
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
The framework worked out exactly who should have been removed, said so, and then threw the answer away.
Stream.toList() is the one that will get you. It looks like a neutral terminal operation and it returns an unmodifiable list. .collect(Collectors.toList()) returns an ArrayList and works. So the difference between a filter that runs and a filter that does not can be a one-line refactor in a completely different class, made by someone who has never heard of @PreFilter.
@PostFilter is not affected, because there the new list is the return value. The same immutable input filters correctly.
The rest of the filtering surface, verified the same way:
Type
@PreFilter
@PostFilter
filterObject is
mutable Collection
works
works
the element
immutable Collection
silent no-op
works
the element
array
IllegalStateException
works
the element
Map
works
works
the Map.Entry — use filterObject.value.…
Stream
works
works
the element
Optional, Page, anything else
IllegalArgumentException: Filter target must be a collection, array, map or stream type
—
Spring Data’s Page being in the last row surprises people, so it is worth saying that this one was run against a real PageImpl rather than inferred: PageImpl implements Slice → Streamable → Iterable, but not Collection, so it falls through every branch of DefaultMethodSecurityExpressionHandler.filter. @PostFilter on a repository method returning Page<T> throws at runtime, not at startup — and even if it worked, filtering a page in memory gives you the wrong page size. Filter in the query.
Two more edges. @PreFilter on a method with more than one argument needs filterTarget, and tells you so with IllegalStateException: Unable to determine the method argument for filtering — at invocation time, so a rarely-exercised method can ship broken. And @PostFilter hands back the same instance it filtered, which means filtering a cached or shared collection deletes elements from it for every future caller. Return a defensive copy from anything you filter.
Where the advice sits, and why a denied write still committed
Six advisors, with orders read out of AuthorizationInterceptorsOrder at runtime rather than transcribed:
Lower order means higher precedence, which means further out in the chain. For the two “before” annotations that reads the obvious way: @PreFilter at 100 runs before @PreAuthorize at 200. For the two “after” annotations it reads backwards, and this catches people who thought they had understood it:
@PostAuthorize at 500 sits further out than @PostFilter at 600, so on the way back out @PostFilter finishes first and @PostAuthorize evaluates returnObject against the already-filtered value. Two methods, identical except for the expected size:
Three elements went in, the method returned three, and @PostAuthorize saw two. If you have both annotations on one method, returnObject is not what the method returned.
The transaction that committed anyway
Now look again at where @Transactional sits. Its advisor defaults to Ordered.LOWEST_PRECEDENCE, which is 2147483647 — larger than every security order above it. So security wraps transactions, not the other way round. Which means the transaction commits, and only then does @PostAuthorize decide the caller may not see the result:
@Transactional
@PostAuthorize("returnObject.owner == authentication.name")
public Account recordAndReturn(String owner) {
this.jdbc.update("insert into audit(owner) values (?)", owner);
return new Account(1, owner, 100);
}
Against a real H2 database, as a caller who is denied:
rows before : 0
recordAndReturn("bob") @PostAuthorize DENIED -> AuthorizationDeniedException
rows after the denial : 1
The caller got a 403 and the row is still there. The fix, if you must keep the shape, is to move the transaction advisor outside the security one so the exception propagates through it and triggers the ordinary rollback-on-RuntimeException rule:
rows before : 0
recordAndReturn("bob") @PostAuthorize DENIED -> AuthorizationDeniedException
rows after the denial : 0
Prefer not to need that. Rolling back on an authorization failure means you did the work and then undid it, and rollback covers exactly one kind of side effect. The message you published, the file you wrote, the outbound call you made are all still gone. If a method both writes and needs a rule that can only be evaluated on its result, that is two methods.
What is actually thrown, and how to change it
Every denial in this article printed AuthorizationDeniedException. That is worth a closer look than it usually gets:
thrown -> org.springframework.security.authorization.AuthorizationDeniedException
is AccessDeniedException -> true
is AuthorizationDeniedException -> true
carries an AuthorizationResult -> ExpressionAuthorizationDecision granted=false
Documentation and most existing writing say “throws AccessDeniedException“, and code written against that still works, because it is a superclass. But the concrete type carries the AuthorizationResult that explains the denial — for a SpEL rule, an ExpressionAuthorizationDecision that knows which expression failed. That is the difference between an audit line reading access denied and one that names the rule.
Two mechanisms exist for changing what happens next, and both are newer than most of the material written about method security. @HandleAuthorizationDenied names a MethodAuthorizationDeniedHandler that returns a value instead of throwing:
maskedBalance() (alice, no ROLE_FINANCE) ALLOWED -> ***masked***
maskedList() (alice, no ROLE_FINANCE) ALLOWED -> []
maskedBalance() (cfo, has ROLE_FINANCE) ALLOWED -> 1,204,993.22
The returned value has to be assignable to the method’s declared return type, so a handler shared across several methods must inspect it — get that wrong and you have traded a clean denial for a ClassCastException at the call site. Use this where a partial answer is genuinely correct: a masked field on a shared DTO, an empty list for a section the caller cannot see. Do not use it to make authorization failures invisible to your own logs.
@AuthorizeReturnObject moves the check onto the returned object’s own accessors:
public class Customer {
public String getName() {
return this.name;
}
@PreAuthorize("hasAuthority('pii:read')")
public String getEmail() {
return this.email;
}
}
@AuthorizeReturnObject
public Customer findCustomer(String name) { ... }
returned instance -> ...Customer$$SpringCGLIB$$0
customer.getName() (no authority needed) ALLOWED -> alice
customer.getEmail() (needs 'pii:read') DENIED -> AuthorizationDeniedException
The returned object is CGLIB-proxied, so everything from the proxy section applies to it in full. The class cannot be final — which means a record will not work — and a final getter is not advised. This is where that earlier detail about final classes stops being trivia: the modern instinct to make every DTO a record is exactly the instinct that quietly disables this feature.
Package correction.AuthorizationProxyFactory lives in org.springframework.security.authorization, not …authorization.method where the reference documentation places it. The implementation, AuthorizationAdvisorProxyFactory, is in …authorization.method; the interface is one package up. proxy(T) is generic and needs no cast. The advisor is registered at order 450 whether or not anything in your application uses the annotation.
Naming rules instead of repeating them
Once you have more than a handful of these, the expressions start to drift. A meta-annotation fixes that with no configuration at all:
The reference documentation says you must publish an AnnotationTemplateExpressionDefaults bean for {value} to be substituted. In Spring Security 7.1.1 you do not — @HasRole("ADMIN") discriminates correctly in a context with no such bean. The reason is in PreAuthorizeExpressionAttributeRegistry, which initialises its scanner with SecurityAnnotationScanners.requireUnique(PreAuthorize.class), and that overload constructs a default internally. The bean’s only real job is setIgnoreUnknown(false), which turns an unrecognised placeholder into an error instead of leaving it sitting in the expression. That is worth publishing it for; making templates work at all is not.
Class-level rules have a sharper edge. @PreAuthorize on the class applies to every method, and a method-level @PreAuthorizereplaces it rather than adding to it — the nearest declaration wins:
inherited from the class (needs ADMIN) DENIED
method-level overrides it (needs USER) ALLOWED
Adding @PreAuthorize("hasRole('USER')") to one method of an @PreAuthorize("hasRole('ADMIN')") class looks like a tightening and is a widening. Different annotation types do compose: a class-level @PreAuthorize and a method-level @PostAuthorize both have to pass.
And if a class inherits two different @PreAuthorize annotations from two interfaces, Spring Security refuses to pick:
AnnotationConfigurationException: Please ensure there is one unique annotation of type
[interface ...PreAuthorize] attributed to public abstract java.lang.String ...ReadsAsUser.read().
Found 2 competing annotations: [@PreAuthorize("hasRole('USER')"), @PreAuthorize("hasRole('ADMIN')")]
Guidance describing this says startup fails. It does not: the context refreshes, the bean is proxied, and the exception appears the first time the method is called. The fix is to annotate the implementation method, which is the nearest declaration and wins outright.
Testing for the thing that does not happen
Everything above shares one property: the failure mode is a check that does not run, on code that returns the correct value. A test that asserts only the happy path passes identically whether the annotation works or not, which makes it actively misleading — it is green, so nobody looks again.
The assertion that carries weight is the negative one:
Those three are written the “wrong” way round on purpose — they assert the broken behaviour, because this suite exists to detect the day Spring Security changes it. In your own codebase, invert them: assert AuthorizationDeniedException, call through the injected bean rather than new, and be suspicious of any security test that has never been seen to fail.
One thing that trips people writing these: this.service.lastSeen as a field read would return the CGLIB proxy’s own uninitialised field, not the target’s. Field access does not go through the proxy any more than self-invocation does. The full suite — fourteen assertions, one per claim in this article — is MethodSecurityTrapsTest.
Auditing a codebase you did not write
$ # annotations on methods a proxy cannot advise
$ grep -rnE '@(Pre|Post)(Authorize|Filter)' --include='*.java' -A3 . | grep -E '(private|static|final) .*\('
$ # @Secured / JSR-250 in a codebase that never enabled them
$ grep -rn 'EnableMethodSecurity' --include='*.java' .
$ # @PostAuthorize on a method that writes
$ grep -rn '@PostAuthorize' --include='*.java' -B3 . | grep -i 'transactional'
Then two runtime checks that take a minute each. Print the advisor chain for a bean you believe is secured — if ((Advised) bean).getAdvisors() is empty, none of its annotations are doing anything. And check getParameters()[0].isNamePresent() on any method whose rule uses #parameterName.
Self-invocation is the one that does not grep. Use “find usages” on each annotated method and look for callers in the same file.
The rest of it
Things this article does not have room for, each with the chapter that reproduces it:
Optional and Spring Data’s Page are not filterable — docs/05
@PreFilter rejects arrays outright, and needs filterTarget past one argument — docs/05
For a Map, filterObject is the Map.Entry, not the value — docs/05
@PostFilter returns the same instance it filtered, so filtering a cached collection empties it for everyone — docs/05
Class-level @AuthorizeReturnObject proxies value types too, unless you publish TargetVisitor.defaultsSkipValueTypes() — docs/06
Multi-value annotation templates need the quotes inside the attribute value — docs/08
The annotation scanner is memoised in a static map per annotation type, which matters if you run differently-configured contexts in one test JVM — docs/08
Repeating the same annotation twice on one method is unsupported; combine with and/or or delegate to a bean — docs/08
Should you use method security at all? If your authorization rules are about URLs and roles, authorizeHttpRequests is simpler, has none of the proxy traps, and is visible in one place instead of scattered across a service layer. Method security earns its keep when the rule depends on arguments or results — “this account, this owner”, “this document, this tenant” — because that information does not exist at the filter chain.
What it should never be is your only layer. Every failure in this article is a method running unchecked; every one of them is harmless if a request-level rule already established that the caller is authenticated and roughly in the right area.
Further reading
The companion module — nine runnable programs, fourteen assertions, and every transcript quoted above, regenerated by one script
Spring Security Context Propagation: The Complete Guide — how the Authentication these annotations read reaches the calling thread in the first place, and what happens to @Async, virtual threads and structured concurrency
No Comments yet!