1
0
Files
jwt-auth-demo/docs/01-architecture.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

4.8 KiB

01 — Architecture

← README · next: filter chain and ordering →

There are two paths through this application, and confusing them is the source of most JWT bugs. The login path runs once and is stateful in the only sense that matters: it sees a password. The request path runs on every subsequent call and sees nothing but a string.

The login path

POST /api/auth/login  {"username":"alice","password":"..."}
        |
        v
  AuthController                     <-- the ONLY place a password is read
        |
        | authenticationManager.authenticate(
        |     UsernamePasswordAuthenticationToken.unauthenticated(user, pass))
        v
  ProviderManager
        |
        v
  DaoAuthenticationProvider
        |  loadUserByUsername -> UserDetails
        |  passwordEncoder.matches(raw, encoded)
        v
  Authentication (authenticated=true, authorities=[ROLE_USER, SCOPE_profile:read])
        |
        v
  TokenService.issueAccessToken(authentication)
        |  JwtClaimsSet: iss aud sub jti iat nbf exp scope roles token_type
        |  NimbusJwtEncoder.encode(...)
        v
  200 {"accessToken":"eyJ...","refreshToken":"eyJ...","tokenType":"Bearer",...}

Note what does not happen: no session is created, no SecurityContext is saved, no cookie is set. The Authentication object built here is used to fill in claims and is then discarded.

The request path

GET /api/me
Authorization: Bearer eyJ...
        |
        v
  FilterChainProxy  ---------------------------------------------+
        |                                                        |
        |  1 DisableEncodeUrlFilter                              |
        |  2 WebAsyncManagerIntegrationFilter                    |
        |  3 SecurityContextHolderFilter        loads context    |
        |  4 HeaderWriterFilter                                  |
        |  5 JwtAuthenticationFilter   <-- ours                  |
        |       resolve Bearer token                             |
        |       jwtDecoder.decode(token)                         |
        |         verify signature                               |
        |         exp / nbf (+/- 60s skew), iss, aud             |
        |         token_type == "access", jti not revoked        |
        |       JwtAuthenticationToken -> SecurityContext        |
        |  6 RequestCacheAwareFilter                             |
        |  7 SecurityContextHolderAwareRequestFilter             |
        |  8 AnonymousAuthenticationFilter                       |
        |  9 SessionManagementFilter                             |
        | 10 ExceptionTranslationFilter    catches what follows  |
        | 11 AuthorizationFilter           permitAll / hasRole   |
        |                                                        |
        +--------------------------------------------------------+
        |
        v
  DispatcherServlet -> @PreAuthorize -> controller

That list is not from memory. It is printed by GET /api/public/filters, which reads FilterChainProxy.getFilterChains() at runtime — see FilterChainReport and step 19 of curl-transcript-hs256.txt.

Where each concern lives

concern class doc
password check AppUsers + DaoAuthenticationProvider
token minting TokenService 05
token verifying JwtAuthenticationFilter 02
claim validation JwtValidatorFactory 07
key material Hs256KeyConfig / Rs256KeyConfig 05
authorization rules SecurityConfig 03
revocation RevokedTokenStore 07

The one-sentence version

A JWT deployment is an issuer that trades a password for a signed claims set, and a verifier that trades a signed claims set for an Authentication — and every failure mode in this repository comes from one of the two doing slightly less checking than the other assumed.