Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.
Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:
- OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
in 7.0, and both configuration classes moved into spring-security-config
- ClientSettings.requireProofKey flipped from false to true, on the authorization server
(1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
- requireProofKey(false) does not make PKCE optional for a public client; the code
verifier is that client's only authentication at the token endpoint
- MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called
Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
20 KiB
spring-auth-demo
Runnable companion code for three articles on ankurm.com:
Three 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. The third
is the somebody else: a real OAuth2 / OIDC provider, with a client and a resource server
in front of it.
This repository was called
jwt-auth-demountil the third project landed. Gitea keeps the old URL redirecting, but please update any bookmarks tospring-auth-demo.
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) |
| Spring Authorization Server | 7.1.1 — the same artifact, now versioned with Spring Security |
| 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/spring-auth-demo.git
cd spring-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 spring-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 3 — your own OAuth2 / OIDC provider
cd spring-auth-demo/authorization-server
./scripts/run.sh auth # the provider, :9000
./scripts/run.sh rs # an API that trusts it, :8090
./scripts/run.sh client # a relying party, :8080
# then open http://127.0.0.1:8080/orders and log in as alice / password
# or drive the whole thing with curl:
./scripts/authcode-pkce.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).
Project 3 — authorization-server/
Three Maven modules, three JVMs, three ports. Nothing about an authorization server is observable without a client to drive the browser redirect and a resource server to accept or reject what comes out.
| module | port | what it is |
|---|---|---|
auth-server/ |
9000 | the provider: clients, PKCE, consent, token customisation |
resource-server/ |
8090 | an API that trusts its tokens |
oidc-client/ |
8080 | a relying party that logs in and calls the API |
Two users: alice / password (ROLE_USER, ROLE_ADMIN) and bob / password
(ROLE_USER).
Three registered clients:
| client | secret | authentication | grants |
|---|---|---|---|
demo-web |
web-secret |
client_secret_basic |
authorization code + refresh |
demo-spa |
— | none (public) |
authorization code + refresh |
demo-service |
service-secret |
client_secret_basic |
client credentials |
Profiles
| module | profile | what it changes |
|---|---|---|
| auth-server | (none) | consent on, PKCE required, custom claims, JWT tokens |
| auth-server | noconsent |
requireAuthorizationConsent(false) on every client |
| auth-server | nopkce |
requireProofKey(false) on the public client |
| auth-server | noclaims |
the OAuth2TokenCustomizer bean is not registered |
| auth-server | opaque |
demo-service gets reference tokens instead of JWTs |
| auth-server | acceptall |
the entry-point matcher without setIgnoredMediaTypes — see docs/authorization-server/09 |
| auth-server | trace |
TRACE logging for org.springframework.security |
| resource-server | noaud |
audience validation off, i.e. the Spring Boot default |
| oidc-client | nopkce |
rebuilds the registration the way Spring Security 6.x would |
Endpoints
| method | path | port | what it is |
|---|---|---|---|
GET |
/.well-known/openid-configuration |
9000 | OIDC discovery. Only present because .oidc(...) is on |
GET |
/.well-known/oauth-authorization-server |
9000 | the OAuth2 metadata document, always present |
GET |
/oauth2/jwks |
9000 | public keys |
GET |
/oauth2/authorize |
9000 | the authorization endpoint |
POST |
/oauth2/token |
9000 | the token endpoint |
POST |
/oauth2/introspect |
9000 | for opaque tokens |
GET |
/oauth2/consent |
9000 | our consent page |
GET |
/userinfo |
9000 | OIDC UserInfo |
GET |
/diag/settings, /diag/clients, /diag/chains |
9000 | diagnostics. Delete before shipping |
GET |
/api/orders |
8090 | needs SCOPE_orders.read |
POST |
/api/orders |
8090 | needs SCOPE_orders.write |
GET |
/api/admin |
8090 | needs ROLE_ADMIN, which only exists via the token customiser |
GET |
/whoami |
8090 | everything the resource server decoded |
GET |
/orders |
8080 | the relying party's page; triggers the whole flow |
Regenerate its captured output with ./authorization-server/scripts/run-all.sh
(no Docker needed; roughly three minutes).
Documentation
One numbered trail across the first two projects, plus a separate set for the third. 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 |
Project 3 — running your own provider
A separate chapter set, indexed at
docs/authorization-server/.
| doc | covers |
|---|---|
| 01 — Versions and the 7.0 move | why there is no SAS version to pin, and which starter to use |
| 02 — The minimum working provider | two chains, and the API that replaced applyDefaultSecurity |
| 03 — Clients, PKCE and the defaults that moved | requireProofKey flipped to true on both sides |
| 04 — The consent page | the form contract, and the redirect loop |
| 05 — Token customisation | the bean the JWT generator looks for, and the one it ignores |
| 06 — The resource server side | what issuer-uri does and does not validate |
| 07 — Diagnostics | reading the effective configuration back out |
| 08 — The relying party | a real browser flow, and the client-side PKCE default |
| 09 — Entry point and the Accept header | why the token endpoint 302s to a login page |
| 10 — Should you run one at all | the honest answer |
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 |
Project 3
Indexed in full at
docs/authorization-server/README.md. The ones worth
opening first:
| file | what it shows |
|---|---|
as-settings-defaults.txt |
requireProofKey false in SAS 1.5.8 and Spring Security 6.5.1, true in 7.1.1 — both sides |
as-legacy-compile-failure.txt |
the pre-7.0 configuration, and the four compiler errors it now produces |
as-authcode-pkce.txt |
the whole authorization-code + PKCE flow, every parameter visible |
as-client-flow-nopkce.txt |
a pre-7.0 client against a 7.1 provider, failing on the client's own error page |
as-entrypoint-accept.txt |
302 vs 401 from the token endpoint, decided by the Accept header |
as-client-credentials-opaque.txt |
a reference token, and what introspection returns for it |
as-test-run.txt |
7 contract tests |
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.
The authorization server's /diag/* endpoints are the same kind of thing: they publish
client ids, grant types, scopes and your filter-chain ordering with no authentication. Its
signing key is generated fresh on every boot, and its users are hard-coded. Read
docs/authorization-server/10-should-you.md
before taking any of it near production.
License
MIT.