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

jwt-auth-demo

Runnable companion code for two articles on ankurm.com:

article code
1 Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1) jwt-authentication/
2 Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation oauth2-resource-server/

Two Maven projects, one shared docs/ tree. The first mints and verifies its own tokens with a hand-written filter; the second verifies tokens minted by somebody else — a real Keycloak, and a stub issuer whose signing keys can be rotated on command.

Everything here was compiled and executed. Every file under docs/output/ is real program output, regenerated by a script — not transcribed by hand.

JDK Temurin 25.0.4.1+1 (current LTS)
Spring Boot 4.1.1
Spring Framework 7.0.9
Spring Security 7.1.1
Nimbus JOSE+JWT 10.9.1
Tomcat 11.0.24
Jackson 3.1.5 (tools.jackson)
Keycloak 26.7.2 (resource server project only)
Caffeine 3.2.4 (resource server project only)

Quickstart

Project 1 — JWT authentication with a hand-written filter

git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
cd jwt-auth-demo/jwt-authentication
./scripts/run.sh hs256          # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256

# in another shell
./scripts/curl-transcript.sh    # the whole flow, end to end

Project 2 — OAuth2 resource server, JWKS and rotation

cd jwt-auth-demo/oauth2-resource-server

# a stub issuer whose JWK Set can be mutated on command
./scripts/run-stub-issuer.sh
./scripts/run-rs.sh stub,roles,audience
./scripts/issuer-audience-demo.sh "stub,roles,audience"

# or a real Keycloak
docker compose -f docker/compose.yaml up -d
./scripts/run-rs.sh keycloak,roles
./scripts/keycloak-demo.sh

Project 1 — jwt-authentication/

Three demo users:

username password authorities
alice alice-password ROLE_USER, SCOPE_profile:read
root root-password ROLE_USER, ROLE_ADMIN, SCOPE_profile:read, SCOPE_admin:read
locked locked-password locked account — always fails login

Profiles

profile what it changes
hs256 (default) Symmetric HMAC signing. One secret signs and verifies.
rs256 RSA signing, plus a real /.well-known/jwks.json endpoint.
(none) Validation by a hand-written OncePerRequestFilter.
resourceserver Validation by Spring Security's built-in oauth2ResourceServer().jwt().
strict Adds the token_type validator to the resource-server chain.
csrfon Turns CSRF on, reproducing the "permitAll() returns 403" failure.
shortlived 2-second access tokens, for observing expiry and clock skew.
trace TRACE logging for org.springframework.security.

Endpoints

method path rule why it exists
POST /api/auth/login permitAll() issues an access + refresh token pair
POST /api/auth/refresh permitAll() rotates the refresh token
POST /api/auth/logout authenticated revokes the presented token by jti
GET /api/public/ping permitAll() reachable with no token at all
GET /api/me authenticated 401 without a token
GET /api/admin/stats hasRole('ADMIN') 403 with a valid non-admin token
GET /api/reports @PreAuthorize scope the method-security twin of the above
GET /api/public/filters permitAll() prints the live filter chain
GET /api/async-demo authenticated SecurityContext across a thread boundary
GET /.well-known/jwks.json permitAll() rs256 profile only

Runs on :8080. Regenerate its captured output with ./jwt-authentication/scripts/run-all.sh.


Project 2 — oauth2-resource-server/

Two applications in one Maven module, started by main class:

application port what it is
ResourceServerApplication 8081 the resource server. Validates only; never mints.
StubIssuerApplication 9000 an authorization server whose JWK Set can be mutated on command

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

Profiles

profile what it changes
stub issuer is the in-repo stub on :9000
keycloak issuer is the Keycloak in docker/compose.yaml
roles a Java JwtAuthenticationConverter mapping Keycloak's nested roles
propsroles the same mapping in configuration only, with the SpEL indexer quoted
propsroles-broken the same, unquoted — fails silently. See docs/14
audience adds a JwtAudienceValidator bean
props audience validation by property instead
attyp a type validator that accepts RFC 9068 at+jwt
springcache a Caffeine JWKS cache with a 5-minute TTL
nottlcache a ConcurrentMapCache with no TTL — the trap in docs/15
hardened the JWKSource built directly, with rate limiting and outage tolerance restored
trace TRACE logging for org.springframework.security
tracespel just enough logging to see a claim expression fail
./scripts/run-rs.sh stub,roles,audience
./scripts/run-rs.sh keycloak,propsroles
./scripts/run-rs.sh stub,roles,nottlcache

Endpoints

method path rule why it exists
GET /api/public/ping permitAll() reachable with no token
GET /api/me authenticated prints the authorities the converter produced
GET /api/admin/stats hasRole('ADMIN') realm role, from realm_access.roles
GET /api/reports @PreAuthorize client role, from resource_access.reports-api.roles
GET /api/public/decoder permitAll() prints the live JWK source chain. Delete before shipping
GET /.well-known/oauth-protected-resource published by Spring Security 7 itself

Stub issuer admin endpoints, for driving a rotation:

method path what it does
POST /admin/publish generate a key and add it to the JWK Set
POST /admin/activate?kid= start signing with that key
POST /admin/retire?kid= remove it from the JWK Set. It can still sign
POST /admin/reset-counter zero the JWKS fetch counter
GET /admin/state active kid, published kids, fetch count
POST /token?sub=&aud=&roles=&expiresInSeconds=&issuedAgoSeconds=&typ=&kid=&issuerOverride= mint anything, correct or not
POST /token/unknown-kid a token whose kid never existed

Regenerate its captured output with ./oauth2-resource-server/scripts/run-all.sh (needs Docker; takes roughly twenty-five minutes, most of it waiting out cache lifetimes).


Documentation

One numbered trail across both projects. Start at docs/01-architecture.md.

doc covers
01 — Architecture the whole request path, drawn
02 — Filter chain and ordering where a custom filter goes, and the four ways to place it wrong
03 — 401 vs 403 ExceptionTranslationFilter's actual decision, and RFC 6750 headers
04 — CSRF vs permitAll why permitAll() still returns 403, and when to disable CSRF
05 — HS256 vs RS256 key handling, JWKS, rotation, algorithm confusion
06 — SecurityContext and statelessness explicit save, repositories, thread boundaries
07 — Edge cases 18 things that bite, each with the fix
08 — Testing what to pin, and the Boot 4 test-slice split
09 — Manual filter vs resource server a side-by-side, and which to pick
10 — Production checklist the list to run before you ship
11 — What changed in Spring Security 7 the 7.x-specific surprises this repo hit
12 — Issuer and audience the two claims that make a token yours, and why aud is unchecked by default
13 — The validator stack what is in it, how to add to it without losing it
14 — The authentication converter claims to authorities, and Keycloak's invisible roles
15 — JWKS caching and key rotation what the cache really does, and how long a retired key lives
16 — What an unknown kid costs rate limiting is off, measured 1:1
17 — Keycloak setup compose, realm import, and three ways it bites
18 — Resource server checklist the list for the resource-server side

Captured output

Project 1

file what it shows
curl-transcript-hs256.txt 20 steps: login → token → 401 → 403 → tamper → refresh → revoke
rs256-demo.txt JWKS, alg=RS256, signature sizes, tamper rejection
csrf-vs-permitall.txt the 403 on a permitAll() endpoint
csrf-trace.txt the TRACE log proving the chain stops at filter 5 of 12
expiry-and-clock-skew.txt a token still accepted 5s after exp
resource-server-loose.txt a refresh token accepted as an access token
resource-server-strict.txt the same request, refused
test-run.txt 13 passing tests

Project 2

file what it shows
rs-issuer-audience.txt wrong iss, wrong aud, expiry either side of the clock skew, at+jwt refused
rs-issuer-audience-attyp.txt the same run with a type validator that accepts at+jwt
rs-converter-default.txt Keycloak-shaped roles, and the 403 they produce untouched
rs-converter-java.txt the same token through a custom converter
rs-converter-properties.txt the same mapping in configuration only
rs-converter-properties-broken.txt one unquoted SpEL indexer, and the silence it produces
rs-decoder-chain.txt the live JWK source chain under three cache configurations
rs-rotation.txt publish, activate and retire, watched from the other side
rs-jwks-amplification.txt 25 bad tokens, 25 JWKS fetches
rs-retired-key-default.txt how long a retired key lives with the default cache
rs-retired-key-nottlcache.txt the same, with a Spring cache that has no TTL
rs-keycloak.txt the same code against a real Keycloak 26.7.2
rs-keycloak-default-converter.txt real Keycloak, roles unmapped
rs-test-run.txt 10 tests pinning the default validator stack

Security note

The keys in jwt-authentication/src/main/resources/, the HMAC secret in its application.yaml, and the Keycloak credentials in docker/realm-demo.json are demo values committed on purpose so the repository runs with no setup. They are public. Never point them at anything you care about — see docs/10-production-checklist.md and docs/18-resource-server-checklist.md.

/api/public/decoder reads private fields by reflection and prints your JWK Set URI and cache timings. It is a diagnostic. Delete it before you ship.

License

MIT.

Description
Runnable companion code for three ankurm.com articles: Spring Security 7.1 JWT authentication, OAuth2 resource server with JWKS and key rotation, and running your own OAuth2 / OIDC provider with Spring Authorization Server 7.1 on Spring Boot 4.1.
Readme MIT 470 KiB
Languages
Java 68.3%
Shell 30%
HTML 1.7%