# jwt-auth-demo Runnable companion code for two articles on [ankurm.com](https://ankurm.com): | | article | code | |---|---|---| | 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) | | 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) | Two Maven projects, one shared [`docs/`](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/`](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 ```bash 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 ```bash 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](docs/14-authentication-converter.md) | | `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](docs/15-jwks-caching-and-rotation.md) | | `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 | ```bash ./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`](docs/01-architecture.md). | doc | covers | |---|---| | [01 — Architecture](docs/01-architecture.md) | the whole request path, drawn | | [02 — Filter chain and ordering](docs/02-filter-chain-and-ordering.md) | where a custom filter goes, and the four ways to place it wrong | | [03 — 401 vs 403](docs/03-401-vs-403.md) | `ExceptionTranslationFilter`'s actual decision, and RFC 6750 headers | | [04 — CSRF vs permitAll](docs/04-csrf-permitall-403.md) | why `permitAll()` still returns 403, and when to disable CSRF | | [05 — HS256 vs RS256](docs/05-hs256-vs-rs256.md) | key handling, JWKS, rotation, algorithm confusion | | [06 — SecurityContext and statelessness](docs/06-securitycontext-and-statelessness.md) | explicit save, repositories, thread boundaries | | [07 — Edge cases](docs/07-edge-cases.md) | 18 things that bite, each with the fix | | [08 — Testing](docs/08-testing.md) | what to pin, and the Boot 4 test-slice split | | [09 — Manual filter vs resource server](docs/09-manual-filter-vs-resource-server.md) | a side-by-side, and which to pick | | [10 — Production checklist](docs/10-production-checklist.md) | the list to run before you ship | | [11 — What changed in Spring Security 7](docs/11-spring-security-7-changes.md) | the 7.x-specific surprises this repo hit | | [12 — Issuer and audience](docs/12-issuer-and-audience.md) | the two claims that make a token yours, and why `aud` is unchecked by default | | [13 — The validator stack](docs/13-validator-stack.md) | what is in it, how to add to it without losing it | | [14 — The authentication converter](docs/14-authentication-converter.md) | claims to authorities, and Keycloak's invisible roles | | [15 — JWKS caching and key rotation](docs/15-jwks-caching-and-rotation.md) | what the cache really does, and how long a retired key lives | | [16 — What an unknown `kid` costs](docs/16-jwks-amplification.md) | rate limiting is off, measured 1:1 | | [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites | | [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side | --- ## Captured output ### Project 1 | file | what it shows | |---|---| | [`curl-transcript-hs256.txt`](docs/output/curl-transcript-hs256.txt) | 20 steps: login → token → 401 → 403 → tamper → refresh → revoke | | [`rs256-demo.txt`](docs/output/rs256-demo.txt) | JWKS, `alg=RS256`, signature sizes, tamper rejection | | [`csrf-vs-permitall.txt`](docs/output/csrf-vs-permitall.txt) | the 403 on a `permitAll()` endpoint | | [`csrf-trace.txt`](docs/output/csrf-trace.txt) | the TRACE log proving the chain stops at filter 5 of 12 | | [`expiry-and-clock-skew.txt`](docs/output/expiry-and-clock-skew.txt) | a token still accepted 5s after `exp` | | [`resource-server-loose.txt`](docs/output/resource-server-loose.txt) | a refresh token accepted as an access token | | [`resource-server-strict.txt`](docs/output/resource-server-strict.txt) | the same request, refused | | [`test-run.txt`](docs/output/test-run.txt) | 13 passing tests | ### Project 2 | file | what it shows | |---|---| | [`rs-issuer-audience.txt`](docs/output/rs-issuer-audience.txt) | wrong `iss`, wrong `aud`, expiry either side of the clock skew, `at+jwt` refused | | [`rs-issuer-audience-attyp.txt`](docs/output/rs-issuer-audience-attyp.txt) | the same run with a type validator that accepts `at+jwt` | | [`rs-converter-default.txt`](docs/output/rs-converter-default.txt) | Keycloak-shaped roles, and the 403 they produce untouched | | [`rs-converter-java.txt`](docs/output/rs-converter-java.txt) | the same token through a custom converter | | [`rs-converter-properties.txt`](docs/output/rs-converter-properties.txt) | the same mapping in configuration only | | [`rs-converter-properties-broken.txt`](docs/output/rs-converter-properties-broken.txt) | one unquoted SpEL indexer, and the silence it produces | | [`rs-decoder-chain.txt`](docs/output/rs-decoder-chain.txt) | the live JWK source chain under three cache configurations | | [`rs-rotation.txt`](docs/output/rs-rotation.txt) | publish, activate and retire, watched from the other side | | [`rs-jwks-amplification.txt`](docs/output/rs-jwks-amplification.txt) | 25 bad tokens, 25 JWKS fetches | | [`rs-retired-key-default.txt`](docs/output/rs-retired-key-default.txt) | how long a retired key lives with the default cache | | [`rs-retired-key-nottlcache.txt`](docs/output/rs-retired-key-nottlcache.txt) | the same, with a Spring cache that has no TTL | | [`rs-keycloak.txt`](docs/output/rs-keycloak.txt) | the same code against a real Keycloak 26.7.2 | | [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped | | [`rs-test-run.txt`](docs/output/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`](oauth2-resource-server/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](docs/10-production-checklist.md) and [docs/18-resource-server-checklist.md](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.