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
5.4 KiB
17 — Keycloak setup, and the three ways the realm import bites
← what an unknown kid costs · next: resource server checklist →
docker compose -f oauth2-resource-server/docker/compose.yaml up -d
cd oauth2-resource-server && ./scripts/run-rs.sh keycloak,roles
./scripts/keycloak-demo.sh
Transcript: rs-keycloak.txt. Keycloak 26.7.2, released
19 August 2026.
The point of running against a real issuer is that nothing in the resource server changes. The application code is identical to the stub runs; one property differs:
spring.security.oauth2.resourceserver.jwt.issuer-uri: http://localhost:8080/realms/demo
Pin KC_HOSTNAME
Keycloak derives the iss claim, and the issuer in its discovery document, from the
request host unless you pin it. A token fetched through localhost:8080 and the same token
fetched through keycloak:8080 from inside a Docker network carry different issuers,
and chapter 12 explains why JwtIssuerValidator will refuse
one of them.
environment:
KC_HOSTNAME: http://localhost:8080
KC_HOSTNAME_STRICT: "false"
This is the fix for the majority of “the token works in curl but not from the application” reports. Both must agree with the value your resource servers are configured with, from wherever they run.
Keycloak does not add an aud for you
An access token from a bare Keycloak client has no aud naming your resource server. Since
chapter 12 argues you should be validating aud, you need a
mapper:
{
"name": "reports-api-audience",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "reports-api",
"access.token.claim": "true"
}
}
Note included.client.audience for a client that exists in the realm, versus
included.custom.audience for an arbitrary string. Using the former means the audience
value is checked against a real client at configuration time.
A clientScopes key in the import replaces the built-ins
This one cost a rebuild. A realm export/import that declares:
"clientScopes": [ { "name": "reports:read", ... } ]
does not add that scope. It replaces the entire set, and Keycloak's built-in
profile, email, roles, acr, basic and web-origins scopes are never created.
Tokens from that realm then have:
- no
realm_accessclaim, because therolesscope is what adds it - no
preferred_username, because theprofilescope is what adds it
which looks exactly like a broken authorities converter, and sends you to chapter 14 to debug something that is not wrong. Verified on 26.7.2 by listing the realm's client scopes through the admin API after import:
=== realm client scopes available ===
offline_access
reports:read
realm-demo.json therefore declares no
clientScopes at all, and gets permissions across using realm roles and client roles
instead.
Users need a name
A user in a realm import with no firstName and lastName fails the password grant with a
message that names nothing useful:
{"error":"invalid_grant","error_description":"Account is not fully set up"}
The realm's default required actions want a complete profile. Supply the names, and
"requiredActions": [].
Read the JWK Set before assuming it holds one key
keys published: 2
kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA
kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA
A JWK Set contains keys you must not verify signatures with. Nimbus's
JWSVerificationKeySelector filters on use and alg before matching kid, so this is
handled — but if you are writing anything that reads a JWK Set yourself, filter on
use: "sig" rather than taking keys[0].
typ is a claim as well as a header
A Keycloak access token has typ: "JWT" in the JOSE header and typ: "Bearer" in the
claim set. JwtTypeValidator reads the header, so Keycloak passes the default type
check. Nothing validates the claim. Do not write a validator that reads
jwt.getClaimAsString("typ") expecting the header value.
start-dev resets everything
Including the signing keys. Every restart is a new realm from the import, and a new kid.
Convenient for the rotation work in chapter 15; a
surprise if you were expecting yesterday's tokens to still verify.
What a real access token looks like here
{
"iss": "http://localhost:8080/realms/demo",
"aud": "reports-api",
"typ": "Bearer",
"scope": "email profile",
"preferred_username": "alice",
"realm_access": { "roles": ["USER"] },
"resource_access": { "reports-api": { "roles": ["reports-reader"] } }
}
Which produces, with the converter from chapter 14:
"authorities": ["FACTOR_BEARER", "ROLE_USER", "ROLE_reports-reader", "SCOPE_email", "SCOPE_profile"]
There is no client_id claim, which is why
JwtValidators.createAtJwtValidator() — which requires one — refuses Keycloak tokens
unless reconfigured. Keycloak puts the client in azp.
← what an unknown kid costs · next: resource server checklist →