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
jwt-auth-demo
Runnable companion code for two articles on ankurm.com:
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.