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.
5.6 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 →
For the comparison Keycloak invites — what it costs to run the equivalent yourself in
Spring — see authorization-server/, and in
particular 10 — Should you run one at all.