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?
answer
- ExceptionTranslationFilter asks AuthenticationTrustResolver
- anonymous/remember-me → AuthenticationEntryPoint (401)
- fully authenticated → AccessDeniedHandler (403)
- 401 = try login; 403 = login won't help
- CSRF failure = AccessDeniedException → 403 for authed user
basics
~20 sBecause 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 sWhen 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// 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
Know anonymous users get sent to login (401), not a 403.
Name ExceptionTranslationFilter as the router and know it distinguishes anonymous from authenticated.
Explain the AuthenticationTrustResolver check, the remember-me nuance, and why you must configure both entry point and handler for APIs.
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)