1
0
Files
jwt-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

6.0 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 tokeninvalid_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. 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:

@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