skip to content

When an AccessDeniedException is thrown, how does ExceptionTranslationFilter decide between commencing the AuthenticationEntryPoint and delegating to the AccessDeniedHandler?

level: seniorimportance: must knowfreq 50%

answer

  1. AuthenticationTrustResolver: anonymous? remember-me?
  2. anonymous/remember-me → entry point (login)
  3. fully authenticated → AccessDeniedHandler (403)
  4. sendStartAuthentication: clear ctx + save request + commence
  5. AnonymousAuthenticationToken never null context

basics

~20 s

It checks the current authentication. If the user is only anonymous or remembered (remember-me), it treats the denial as 'not really logged in' and calls the AuthenticationEntryPoint (prompt to log in). If the user is fully authenticated, it delegates to the AccessDeniedHandler (403).

solid answer

~40 s

On an `AccessDeniedException`, ExceptionTranslationFilter inspects the current `Authentication` via an `AuthenticationTrustResolver`. If `isAnonymous(auth)` or `isRememberMe(auth)` is true, the user hasn't genuinely proven who they are, so a 403 would be unhelpful — the filter instead calls `sendStartAuthentication`, which clears the context, saves the request in the `RequestCache`, and commences the `AuthenticationEntryPoint` (e.g. redirect to login). If the user is *fully* authenticated (real login), the denial is a genuine authorization failure, so it delegates to the `AccessDeniedHandler`, which typically returns `403 Forbidden`. This is why an anonymous browser hitting a protected page gets a login screen rather than a 403, while a logged-in user lacking a role gets 403. The trust resolver is `AuthenticationTrustResolverImpl` by default and is configurable.

code

java · 17 lines
java
// Simplified from ExceptionTranslationFilter#handleAccessDeniedException
private void handleAccessDeniedException(HttpServletRequest req, HttpServletResponse res,
        FilterChain chain, AccessDeniedException ex) throws IOException, ServletException {
    Authentication auth = securityContextHolderStrategy.getContext().getAuthentication();
    boolean notFullyAuthenticated =
        this.authenticationTrustResolver.isAnonymous(auth)
            || this.authenticationTrustResolver.isRememberMe(auth);
    if (notFullyAuthenticated) {
        // Give the user a chance to log in (or step up from remember-me)
        sendStartAuthentication(req, res, chain,
            new InsufficientAuthenticationException(
                "Full authentication is required to access this resource"));
    } else {
        // Genuinely authenticated but forbidden -> 403 via handler
        this.accessDeniedHandler.handle(req, res, ex);
    }
}

go deeper

for a junior

Grasp the headline: not-really-logged-in denials go to login; real denials give 403.

for a middle

Name the two special cases (anonymous, remember-me) and that a trust resolver decides.

for a senior

Explain sendStartAuthentication (clear context, save request, commence) and the InsufficientAuthenticationException it raises.

for a principal

Discuss step-up authentication from remember-me, custom trust resolvers for bespoke tokens, and how this branching shapes API vs browser error semantics.

## The core decision Both the `AuthenticationException` path *and* one branch of the `AccessDeniedException` path can end up commencing the entry point. The reason lies in a subtle question: **is the current user genuinely authenticated?** When `AuthorizationFilter` denies a request it throws `AccessDeniedException`. `ExceptionTranslationFilter` then asks its `AuthenticationTrustResolver`: ```java if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) { // treat as "needs to authenticate" -> commence entry point sendStartAuthentication(...); } else { // genuine authorization failure -> 403 accessDeniedHandler.handle(...); } ``` ## Why the two special cases - **Anonymous** — Spring Security represents an unauthenticated user with an `AnonymousAuthenticationToken` (via `AnonymousAuthenticationFilter`), so the `SecurityContext` is never truly empty. A denial for an anonymous user really means "you haven't logged in yet." Returning `403` would be a dead end; instead the user is sent to the login flow. This is exactly how an anonymous browser visiting `/admin` ends up at `/login`. - **Remember-me** — A user identified only by a persistent remember-me cookie is *lightly* authenticated. Sensitive operations often require a fresh, full login. So when a remember-me-only principal is denied, Spring gives them the chance to re-authenticate through the entry point rather than flatly refusing. (Full-authentication-required rules build on this via `fullyAuthenticated`.) ## What `sendStartAuthentication` does 1. Clears the `SecurityContextHolder` (the failed/anonymous context shouldn't linger). 2. Saves the current request into the `RequestCache` (default `HttpSessionRequestCache`) as a `SavedRequest`, so after login the user can be redirected back to where they were. 3. Calls `authenticationEntryPoint.commence(...)`. ## The trust resolver `AuthenticationTrustResolver` (default `AuthenticationTrustResolverImpl`) is the component that classifies an `Authentication` as anonymous, remember-me, or fully authenticated. You can supply a custom one via `http.exceptionHandling(...)`/the shared object if you have custom token types. ## Gotchas - If a request is **fully authenticated** and denied, you will **never** see the entry point — you get the `AccessDeniedHandler` (403). Candidates often wrongly expect anonymous-style behaviour here. - Conversely, a common surprise: an anonymous API caller may receive a **login redirect** instead of the `401` they expect, precisely because the AccessDeniedException-for-anonymous branch routes to the entry point. Configuring the entry point correctly (401) fixes this. - The AccessDeniedHandler's *own* policy (what body/status a real 403 produces) is a separate concern owned by HTTP authorization configuration; here we only care about the *routing decision*. ## When to use / customize Customize the trust resolver only for exotic token setups. Far more commonly you customize the entry point (so anonymous denials produce the right 401/redirect) and leave the branching logic alone.

  • Why does an anonymous user hitting a protected URL get redirected to login instead of a 403?
    The AccessDeniedException-for-anonymous branch routes to the AuthenticationEntryPoint (via an InsufficientAuthenticationException), because a 403 would be a dead end for someone who simply hasn't logged in yet.
  • Which component classifies the Authentication as anonymous vs remember-me vs fully authenticated?
    AuthenticationTrustResolver (default AuthenticationTrustResolverImpl). It's configurable if you use custom token types.

saying these in an interview costs you the question

  • Claiming every AccessDeniedException produces a 403 (anonymous/remember-me denials commence the entry point instead)
  • Thinking a fully authenticated forbidden user is redirected to login
  • Not knowing anonymous users carry an AnonymousAuthenticationToken (so the context isn't null)

context