Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/ - login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end - HS256 and RS256 variants (RS256 publishes a real JWKS endpoint) - the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison - 11 documentation chapters under docs/, interlinked with the code - docs/output/ is real captured output, regenerated by scripts/run-all.sh - 13 passing tests pinning the 401-vs-403 contract and the CSRF failure Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
5.9 KiB
03 — 401 vs 403
← filter chain · next: CSRF vs permitAll →
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:
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.
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
.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.
BearerTokenAuthenticationEntryPointis inorg.springframework.security.oauth2.server.resource.web, whileBearerTokenAccessDeniedHandleris one level deeper in…resource.web.accessandBearerTokenAuthenticationFilteris 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:
@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 |
| 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 |