1
0
Files
spring-auth-demo/docs/03-401-vs-403.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

149 lines
6.0 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`](../jwt-authentication/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 |