1
0
Files
spring-security-demo/method-security/docs/06-denied-handling.md
asmhatre 5e9e7f1b12 Split into per-article modules and add the method-security module
Moves the existing virtual-thread/context-propagation project into
context-propagation/ and adds method-security/ for the Spring Security 7
method-security article: nine runnable demos, fourteen assertions, and every
transcript the article quotes, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
2026-08-25 02:01:29 +00:00

4.6 KiB

← 05 · filtering · chapter index · next: ordering and transactions →

06 · Denial: what is thrown, and how to change it

Run: java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo7DeniedHandling Output: output/demo7.txt Source: Demo7DeniedHandling.java

The exception

thrown                          -> org.springframework.security.authorization.AuthorizationDeniedException
is AccessDeniedException        -> true
is AuthorizationDeniedException -> true
carries an AuthorizationResult  -> ExpressionAuthorizationDecision granted=false

Documentation and older posts say "throws AccessDeniedException", and code written against that still catches it. But the concrete type is AuthorizationDeniedException, and it 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 log line saying "access denied" and one saying which rule denied it.

Returning something instead of throwing

@HandleAuthorizationDenied names a MethodAuthorizationDeniedHandler bean, which gets the MethodInvocation and the AuthorizationResult and returns a value in place of the throw:

@PreAuthorize("hasRole('FINANCE')")
@HandleAuthorizationDenied(handlerClass = MaskingHandler.class)
public String maskedBalance() { return "1,204,993.22"; }
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 must be assignable to the method's declared return type, so a handler shared across methods has to inspect it — the demo's handler returns List.of() for a List return type and a masked string otherwise. A handler that gets this wrong fails with a ClassCastException at the call site, which is worse than the denial it was replacing.

Use this where a partial answer is genuinely correct — a masked field on a shared DTO, an empty list for a section the user cannot see. Do not use it to make an authorization failure invisible to your own logs.

@AuthorizeReturnObject

Moves the check from the method that returns an object onto the 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, which means chapter 04 applies to it in full: the class cannot be final, so a record will not work, and a final getter is not advised. The Customer in this demo is deliberately a plain class for exactly that reason.

The same thing without the annotation, through the container's AuthorizationProxyFactory:

AuthorizationProxyFactory factory = ctx.getBean(AuthorizationProxyFactory.class);
Customer wrapped = factory.proxy(raw);   // generic, no cast needed
raw.getEmail()      (unproxied object)         ALLOWED  -> alice@example.com
wrapped.getEmail()  (proxied object)           DENIED   -> AuthorizationDeniedException

Package correction: AuthorizationProxyFactory lives in org.springframework.security.authorization, not org.springframework.security.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 returns T.

Two more things worth knowing before you reach for it:

  • The authorizeReturnObject advisor is registered at order 450 (AuthorizationInterceptorsOrder.SECURE_RESULT) whether or not anything in your application uses @AuthorizeReturnObject — visible in output/demo6.txt.
  • At class level it proxies every return value, including String and boxed primitives. Publish AuthorizationAdvisorProxyFactory.TargetVisitor.defaultsSkipValueTypes() if you go that route.

← 05 · filtering · chapter index · next: ordering and transactions →