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.
This commit is contained in:
148
docs/03-401-vs-403.md
Normal file
148
docs/03-401-vs-403.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user