Fourth Maven project in the repository. Registration and authentication run end to end with no browser and no hardware key: VirtualAuthenticator emits real CBOR attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java drives the live HTTP endpoints with them. Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
spring-auth-demo
Runnable companion code for four articles on ankurm.com:
Four 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. The fourth gets rid of the password entirely, and runs both WebAuthn
ceremonies with no browser and no hardware key.
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) |
| WebAuthn4J | 0.31.9.RELEASE (passkeys project only) |
| H2 | 2.4.240 (passkeys 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 4 — passkeys, with no browser and no hardware key
cd spring-auth-demo/passkeys
./scripts/run.sh # http://localhost:8080/login, user/password
./scripts/ceremony.sh # registration and authentication, end to end
./scripts/counter.sh # a replayed signature counter, accepted
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; a few minutes).
Project 4 — passkeys/
One application on :8080, with rpId localhost — the only host browsers treat as a
secure context without TLS.
Two users: user / password and admin / password. Both start with a password, because
a passkey cannot be a user's first credential.
The interesting part is that this module needs no browser.
VirtualAuthenticator
is a software authenticator that emits genuine CBOR attestation objects and genuine ES256
assertion signatures; tools/PasskeyCeremony.java
drives the real HTTP endpoints with it, CSRF tokens and cookie jar included.
Profiles
| profile | what it changes |
|---|---|
| (none) | rpId localhost, user verification preferred, credentials in memory |
uvrequired |
UserVerificationRequirement.REQUIRED on both ceremonies |
attestationdirect |
asks for DIRECT attestation, and accepts none anyway |
badorigin |
the relying party expects an origin the client will not send |
jdbc |
H2, using the DDL that ships inside spring-security-web |
trace |
DEBUG for Spring Security and WebAuthn4J — the only place failures are visible |
Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
POST |
/webauthn/register/options |
authenticated | issues the creation options; 400 if nobody is logged in |
POST |
/webauthn/register |
authenticated | verifies the attestation; 500 on any failure |
DELETE |
/webauthn/register/{id} |
owner only | guarded by CredentialRecordOwnerAuthorizationManager |
POST |
/webauthn/authenticate/options |
permitAll() |
issues the request options |
POST |
/login/webauthn |
permitAll() |
verifies the assertion; a bare 401 on any failure |
POST |
/ott/generate |
permitAll() |
the magic-link fallback. No rate limit ships with it |
POST |
/login/ott |
permitAll() |
redeems a one-time token, once |
GET |
/me |
authenticated | prints which factor the session actually carries |
GET |
/passkey-only |
hasAuthority('FACTOR_WEBAUTHN') |
step-up: a magic-link session gets redirected, not admitted |
GET |
/diag/credentials |
authenticated | the stored CredentialRecord. Delete before shipping |
GET |
/diag/filters |
authenticated | the live filter chain |
Regenerate its captured output with ./passkeys/scripts/run-all.sh (no Docker; a few minutes).
Documentation
One numbered trail across the first two projects, plus a separate set for the third and the
fourth. 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 |
Project 4 — passkeys and WebAuthn
A separate chapter set, indexed at
docs/passkeys/.
| doc | covers |
|---|---|
| 01 — Versions, artifacts and the 7.0 split | the dependency spring-boot-starter-security does not give you |
| 02 — The minimum configuration | six endpoints from one DSL block, and the bean that silently disables it |
| 03 — The two ceremonies | what is on the wire, and what every default in the options object means |
| 04 — A software authenticator | how to execute a passkey ceremony in CI, with no browser |
| 05 — The defaults | user verification is optional, and asking for attestation is not checking it |
| 06 — The bootstrap problem | a passkey cannot be a user's first credential |
| 07 — Failure modes | why registration failures are 500s and login failures are bare 401s |
| 08 — The one-time-token fallback | the way in, the way back, and the rate limit that does not exist |
| 09 — Persistence | the in-memory default, the missing DDL, and the column you must not drop |
| 10 — The signature counter | stored on every login, compared against on none |
| 11 — Should you build this | the honest answer, and what the afternoon actually costs |
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 |
Project 4
Indexed in full at docs/passkeys/README.md. The ones worth
opening first:
| file | what it shows |
|---|---|
pk-ceremony.txt |
both WebAuthn ceremonies, end to end, no browser |
pk-counter.txt |
a signature counter of 1 accepted after the server stored 3 |
pk-user-verification.txt |
uvInitialized: false, and a successful login anyway |
pk-attestation.txt |
"attestation":"direct" requested, fmt: "none" accepted |
pk-origin.txt |
BadOriginException, and the 500 and 401 it produces |
pk-step-up.txt |
?factor.type=webauthn&factor.reason=missing |
pk-bootstrap.txt |
a 400 from /webauthn/register/options with no session |
pk-filters.txt |
all 25 filters, and where the WebAuthn four land |
pk-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.
The passkeys project's /diag/credentials prints credential ids and user handles to any
authenticated caller, and ConsoleOneTimeTokenHandler writes live one-time tokens to a file
in /tmp so the scripts can read them. Both are demo affordances. Delete them, and read
docs/passkeys/11-should-you.md first.
License
MIT.