1
0
Files
jwt-auth-demo/docs/17-keycloak-setup.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
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
2026-08-23 11:00:56 +00:00

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_access claim, because the roles scope is what adds it
  • no preferred_username, because the profile scope 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 →