skip to content

Why does an anonymous user hitting a protected URL get 401 (redirect to login) rather than a 403 from the AccessDeniedHandler, even though authorization technically failed?

level: principalimportance: should knowfreq 22%

answer

  1. ExceptionTranslationFilter asks AuthenticationTrustResolver
  2. anonymous/remember-me → AuthenticationEntryPoint (401)
  3. fully authenticated → AccessDeniedHandler (403)
  4. 401 = try login; 403 = login won't help
  5. CSRF failure = AccessDeniedException → 403 for authed user

basics

~20 s

Because the ExceptionTranslationFilter checks whether the user is anonymous. If they are, it triggers authentication (401/login) via the AuthenticationEntryPoint instead of the AccessDeniedHandler, since the right fix is to log in, not to show a 403.

solid answer

~40 s

When authorization fails, an AccessDeniedException reaches the ExceptionTranslationFilter. Before choosing a response it asks the AuthenticationTrustResolver whether the current Authentication is anonymous (or remember-me only). If it is not fully authenticated, the filter reasons that the caller could plausibly gain access by authenticating, so it saves the request and delegates to the AuthenticationEntryPoint (start login / 401) rather than the AccessDeniedHandler. Only when the user is already fully authenticated does it call the AccessDeniedHandler for a 403. This is why your custom AccessDeniedHandler never runs for anonymous requests — a common source of confusion when someone expects a 403 on every denied request. It's deliberate: 401 means 'authenticate and retry', 403 means 'you're known and still forbidden'.

code

java · 16 lines
java
// For a stateless API, wire BOTH sides so each case returns JSON.
@Bean
SecurityFilterChain chain(HttpSecurity http,
                          AuthenticationEntryPoint restEntryPoint,   // 401 JSON
                          AccessDeniedHandler restDeniedHandler)     // 403 JSON
        throws Exception {
    http.authorizeHttpRequests(a -> a
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .exceptionHandling(ex -> ex
            .authenticationEntryPoint(restEntryPoint)  // anonymous -> here (401)
            .accessDeniedHandler(restDeniedHandler));  // authed+denied -> here (403)
    return http.build();
}
// Anonymous GET /admin/**  -> restEntryPoint (401)
// ROLE_USER GET /admin/**  -> restDeniedHandler (403)

go deeper

for a junior

Know anonymous users get sent to login (401), not a 403.

for a middle

Name ExceptionTranslationFilter as the router and know it distinguishes anonymous from authenticated.

for a senior

Explain the AuthenticationTrustResolver check, the remember-me nuance, and why you must configure both entry point and handler for APIs.

for a principal

Reason about the full 401/403 contract, step-up (fullyAuthenticated/remember-me), CSRF denials flowing through the handler, and anonymous-disabled behavior.

**Setup.** Spring Security assigns every unauthenticated request an *anonymous* `Authentication` (an `AnonymousAuthenticationToken`) via the `AnonymousAuthenticationFilter`, so the `SecurityContext` is never literally empty. That means a URL rule like `.anyRequest().authenticated()` fails for anonymous users by throwing an `AccessDeniedException` — the same exception type used for a *real* authorization failure. **The decision point.** The `ExceptionTranslationFilter` catches that `AccessDeniedException` and must decide between two very different responses: - **AuthenticationEntryPoint** → *commence authentication* (redirect to login page, or send 401 for an API). - **AccessDeniedHandler** → *403 Forbidden*. It uses an `AuthenticationTrustResolver` (`AuthenticationTrustResolverImpl`) to inspect the current `Authentication`: ```text if (authentication is anonymous OR remember-me) { // caller might succeed by logging in properly save the current request (RequestCache) call AuthenticationEntryPoint.commence(...) // 401 / redirect to login } else { // caller is fully authenticated and still not allowed call AccessDeniedHandler.handle(...) // 403 } ``` **Why the distinction matters.** HTTP semantics: **401 Unauthorized** means *authentication is required or has failed — try authenticating*; **403 Forbidden** means *the server understood the request and refuses; re-authenticating won't help*. Routing anonymous denials to 401 is correct because logging in **could** grant access. Routing an already-authenticated user's denial to 403 is correct because a different login won't help — they simply lack the authority. **Remember-me nuance.** A remember-me authentication is treated as *not fully trusted*. If a rule requires `fullyAuthenticated()` and only a remember-me token is present, the trust resolver reports it as remember-me and the filter sends the user to re-authenticate (entry point) rather than a 403. This lets step-up authentication work for sensitive operations. **The practical trap.** Engineers write a custom `AccessDeniedHandler` expecting *every* denied request to hit it, then observe that unauthenticated API calls return a login redirect / 401 and their handler never fires. The fix is to also configure the `AuthenticationEntryPoint` (the sibling 401 component) — e.g. an entry point that returns `401` JSON for APIs — because the two components own disjoint cases. For a stateless REST API you typically set a JSON-returning `AuthenticationEntryPoint` *and* a JSON-returning `AccessDeniedHandler`. **Edge cases.** - If you *disable* anonymous (`http.anonymous(AbstractHttpConfigurer::disable)`), an unauthenticated request has a `null` authentication; the trust resolver still classifies it as anonymous/untrusted, so behavior (route to entry point) is the same. - CSRF failures throw `AccessDeniedException` too (`InvalidCsrfTokenException`/`MissingCsrfTokenException` are subclasses), so for an *authenticated* user a bad CSRF token routes to your `AccessDeniedHandler` (403) — a legitimate reason your handler fires on non-authorization denials. **Design takeaway.** Treat `AccessDeniedHandler` and `AuthenticationEntryPoint` as a pair. The trust-resolver split is the mechanism that guarantees each 403 truly represents an authenticated-but-forbidden caller.

  • Which component decides between the entry point and the access denied handler, and how?
    The ExceptionTranslationFilter, using an AuthenticationTrustResolver. If the current Authentication is anonymous or remember-me (not fully authenticated) it commences the AuthenticationEntryPoint (401); otherwise it invokes the AccessDeniedHandler (403).
  • Can your AccessDeniedHandler ever fire for a reason other than a role/authority check?
    Yes. CSRF failures (InvalidCsrfTokenException, MissingCsrfTokenException) are subclasses of AccessDeniedException, so an authenticated user submitting a request with a missing/invalid CSRF token is routed to the AccessDeniedHandler and gets a 403.

saying these in an interview costs you the question

  • Claiming anonymous denied requests hit the AccessDeniedHandler
  • Not knowing the AuthenticationTrustResolver makes the decision
  • Thinking 401 and 403 are interchangeable
  • Believing an empty SecurityContext (not anonymous token) changes the routing
  • Assuming AccessDeniedHandler only ever fires for role checks (ignoring CSRF)

context