# 03 — 401 vs 403 [← filter chain](02-filter-chain-and-ordering.md) · [next: CSRF vs permitAll →](04-csrf-permitall-403.md) ## The one-line rule > **401** — I do not know who you are. > **403** — I know who you are, and you may not do this. Everything else follows from that. The confusion comes from the fact that Spring Security decides which one to send in a filter your token never reaches, using an `Authentication` your filter may or may not have installed. ## The actual decision `ExceptionTranslationFilter` wraps the rest of the chain and catches exactly two exception types: ```java try { filterChain.doFilter(request, response); // AuthorizationFilter runs in here } catch (AccessDeniedException | AuthenticationException ex) { if (!authenticated || ex instanceof AuthenticationException) { startAuthentication(); // -> AuthenticationEntryPoint -> 401 } else { accessDenied(); // -> AccessDeniedHandler -> 403 } } ``` Read the condition carefully. `AuthorizationFilter` throws `AccessDeniedException` for *both* "no credentials" and "wrong credentials". The 401/403 split is decided by `authenticated` — which is false when the current `Authentication` is anonymous or `null`. So: | you sent | context holds | `AuthorizationFilter` | translated to | |---|---|---|---| | nothing | `AnonymousAuthenticationToken` | `AccessDeniedException` | **401** | | a valid token, insufficient authority | `JwtAuthenticationToken` | `AccessDeniedException` | **403** | | an invalid token | *(filter cleared it and stopped)* | never reached | **401** | The third row is the one people get wrong. An invalid token must not be allowed to fall through to anonymous — otherwise a forged token on an admin endpoint yields 403, which tells the caller "your token is fine, your role is not". It is not fine. ## What the wire looks like All from [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt). **No token** — bare challenge, no error code, because there is nothing wrong with a token that was never presented: ``` HTTP 401 WWW-Authenticate: Bearer realm="jwt-auth-demo", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" ``` **Tampered token** — `invalid_token`, per RFC 6750 §3.1: ``` HTTP 401 WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", ... ``` **Valid token, missing role** — note this is a **403** that still carries a `WWW-Authenticate` header: ``` HTTP 403 WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" ``` ## Wiring it ```java .exceptionHandling(ex -> ex .authenticationEntryPoint(bearerTokenEntryPoint()) // 401 .accessDeniedHandler(bearerTokenAccessDeniedHandler()) // 403 ); @Bean AuthenticationEntryPoint bearerTokenEntryPoint() { BearerTokenAuthenticationEntryPoint entryPoint = new BearerTokenAuthenticationEntryPoint(); entryPoint.setRealmName("jwt-auth-demo"); return entryPoint; } @Bean AccessDeniedHandler bearerTokenAccessDeniedHandler() { return new BearerTokenAccessDeniedHandler(); } ``` > **Package trap.** `BearerTokenAuthenticationEntryPoint` is in > `org.springframework.security.oauth2.server.resource.web`, while > `BearerTokenAccessDeniedHandler` is one level deeper in `…resource.web.access` and > `BearerTokenAuthenticationFilter` is in `…resource.web.authentication`. Three siblings, > three packages. Auto-import will pick the wrong one. The same `AuthenticationEntryPoint` bean is passed to `JwtAuthenticationFilter`, so a 401 looks identical whether it came from the filter or from `ExceptionTranslationFilter`. Two different 401 shapes for the same logical failure is a needless client bug. ## Login failures are a third path `POST /api/auth/login` calls `AuthenticationManager` **from a controller**, so a `BadCredentialsException` is an ordinary MVC exception by the time anything security-shaped could see it. `AuthenticationEntryPoint` is never invoked. It needs its own `@RestControllerAdvice` — see [`ApiExceptionHandler`](../src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java): ```java @ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class}) public ProblemDetail onAuthenticationFailure(AuthenticationException ex) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED); problem.setTitle("Authentication failed"); problem.setDetail("Invalid username or password"); return problem; } ``` Every branch answers with the **same body**. `LockedException` and `BadCredentialsException` producing different messages is a user-enumeration oracle: "account locked" confirms the username exists. `AuthenticationFlowTests` pins this by byte-comparing the two responses. Without this handler the default is a 500 or a 403, depending on your error handling — neither of which is what a client should see for a wrong password. ## Symptom → cause | symptom | cause | |---|---| | 403 on every endpoint, even with a good token | CSRF — see [doc 04](04-csrf-permitall-403.md) | | 401 on every endpoint, even with a good token | filter after `AuthorizationFilter`, or a decoder pinned to the wrong algorithm | | 403 where you expected 401 | invalid token silently falling through to anonymous | | 401 where you expected 403 | filter cleared the context on a *valid* token — usually a validator throwing | | 500 on a wrong password | no `@RestControllerAdvice` for `AuthenticationException` | | 403 with `WWW-Authenticate: Bearer` and no error code | not a token problem — `CsrfFilter` delegating to your bearer handler |