1
0
Files
jwt-auth-demo/docs/03-401-vs-403.md
asmhatre 4a8dab6739 Spring Security 7.1 JWT authentication on Spring Boot 4.1
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.
2026-08-22 06:34:43 +00:00

149 lines
5.9 KiB
Markdown

# 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 |