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.
4.6 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.