1
0
Files
spring-auth-demo/README.md
Ankur Mhatre f6dd692177 Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback
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.
2026-08-25 23:00:27 +05:30

26 KiB

spring-auth-demo

Runnable companion code for four 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/
3 Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider authorization-server/
4 Passkeys and WebAuthn with Spring Security 7 passkeys/

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-demo until the third project landed. Gitea keeps the old URL redirecting, but please update any bookmarks to spring-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.