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.
149 lines
5.9 KiB
Markdown
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 |
|