1
0

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
This commit is contained in:
2026-08-23 11:00:56 +00:00
parent 4a8dab6739
commit 4dc45d5e00
101 changed files with 4087 additions and 64 deletions

View File

@@ -74,20 +74,20 @@ Authorization: Bearer eyJ...
That list is not from memory. It is printed by `GET /api/public/filters`, which reads
`FilterChainProxy.getFilterChains()` at runtime — see
[`FilterChainReport`](../src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java)
[`FilterChainReport`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java)
and step 19 of [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt).
## Where each concern lives
| concern | class | doc |
|---|---|---|
| password check | [`AppUsers`](../src/main/java/com/ankurm/jwtauth/config/AppUsers.java) + `DaoAuthenticationProvider` | — |
| token minting | [`TokenService`](../src/main/java/com/ankurm/jwtauth/auth/TokenService.java) | [05](05-hs256-vs-rs256.md) |
| token verifying | [`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) | [02](02-filter-chain-and-ordering.md) |
| claim validation | [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java) | [07](07-edge-cases.md) |
| key material | [`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) / [`Rs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) | [05](05-hs256-vs-rs256.md) |
| authorization rules | [`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) | [03](03-401-vs-403.md) |
| revocation | [`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java) | [07](07-edge-cases.md) |
| password check | [`AppUsers`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/AppUsers.java) + `DaoAuthenticationProvider` | — |
| token minting | [`TokenService`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/TokenService.java) | [05](05-hs256-vs-rs256.md) |
| token verifying | [`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) | [02](02-filter-chain-and-ordering.md) |
| claim validation | [`JwtValidatorFactory`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java) | [07](07-edge-cases.md) |
| key material | [`Hs256KeyConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) / [`Rs256KeyConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) | [05](05-hs256-vs-rs256.md) |
| authorization rules | [`SecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) | [03](03-401-vs-403.md) |
| revocation | [`RevokedTokenStore`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java) | [07](07-edge-cases.md) |
## The one-sentence version

View File

@@ -84,7 +84,7 @@ FilterRegistrationBean<JwtAuthenticationFilter> disableAutoRegistration(
```
This repository sidesteps it: `JwtAuthenticationFilter` is constructed with `new` inside
[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) and is
[`SecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) and is
never a bean.
### 4. Extending `GenericFilterBean` instead of `OncePerRequestFilter`
@@ -97,7 +97,7 @@ authentication side effects fire twice.
## The five details inside the filter
From
[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java):
[`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java):
**1. No token is not an error.** Continue the chain. This is what keeps `permitAll()`
endpoints reachable.

View File

@@ -116,7 +116,7 @@ Two different 401 shapes for the same logical failure is a needless client bug.
`BadCredentialsException` is an ordinary MVC exception by the time anything
security-shaped could see it. `AuthenticationEntryPoint` is never invoked. It needs its
own `@RestControllerAdvice` — see
[`ApiExceptionHandler`](../src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java):
[`ApiExceptionHandler`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java):
```java
@ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class})

View File

@@ -21,7 +21,7 @@ HTTP 403
WWW-Authenticate: Bearer
```
Reproduce it: `./scripts/run.sh hs256,csrfon` then `./scripts/csrf-demo.sh`. Captured in
Reproduce it: `./jwt-authentication/scripts/run.sh hs256,csrfon` then `./jwt-authentication/scripts/csrf-demo.sh`. Captured in
[`csrf-vs-permitall.txt`](output/csrf-vs-permitall.txt).
Note the response body is empty and the header mentions `Bearer` — which sends people

View File

@@ -50,7 +50,7 @@ The decoder side, confusingly, *does* use `macAlgorithm(..)` / `signatureAlgorit
**The secret must be ≥ 256 bits.** Nimbus enforces the JWA rule that an HMAC key is at
least as long as its digest; a shorter one throws `KeyLengthException` at encoder
construction, not at first request.
[`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails
[`Hs256KeyConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails
fast with a clearer message. A short secret is also brute-forceable offline — the
attacker has the ciphertext, the plaintext, and unlimited attempts.
@@ -84,7 +84,7 @@ key rotation is impossible: the verifier cannot tell which of two published keys
### Publishing the public half
[`Rs256KeyConfig.JwkSetEndpoint`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java)
[`Rs256KeyConfig.JwkSetEndpoint`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java)
serves a real JWK Set. From [`rs256-demo.txt`](output/rs256-demo.txt):
```json

View File

@@ -49,7 +49,7 @@ this.contextRepository.saveContext(context, request, response); // <-- easy to
For a genuinely stateless API `saveContext` on a `NullSecurityContextRepository` is a
no-op, so omitting it appears to work — until an `ERROR` dispatch, a `FORWARD`, or an
async re-dispatch clears the `ThreadLocal` and the principal vanishes on `/error`.
[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java)
[`JwtAuthenticationFilter`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java)
uses `RequestAttributeSecurityContextRepository`, which survives a dispatch without ever
touching a session — the right middle ground.

View File

@@ -21,8 +21,8 @@ decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), audience));
```
See [`AudienceValidator`](../src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java)
and [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java).
See [`AudienceValidator`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java)
and [`JwtValidatorFactory`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java).
---
@@ -46,7 +46,7 @@ no scopes. The caller is authenticated as alice with no privileges, so `/api/me`
while `/api/admin/stats` does not. A partial compromise is still a compromise.
Fix: a `token_type` claim and a validator that checks it —
[`AccessTokenTypeValidator`](../src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java).
[`AccessTokenTypeValidator`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java).
---
@@ -79,7 +79,7 @@ new DelegatingOAuth2TokenValidator<>(
"Logout" that deletes the token client-side is not revocation — the token stays valid
until `exp` and works from anywhere it was copied. The minimum viable fix is a `jti`
claim plus a denylist checked on every request:
[`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java).
[`RevokedTokenStore`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java).
```java
if (this.revokedTokens.isRevoked(jwt.getId())) {

View File

@@ -91,7 +91,7 @@ this.mvc.perform(post("/api/auth/login").with(csrf()) ... )
```
Convenient, and it will make a test pass against a configuration that 403s in production.
[`CsrfBreaksPermitAllTests`](../src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java)
[`CsrfBreaksPermitAllTests`](../jwt-authentication/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java)
deliberately has both tests: one asserting the 403 **without** `csrf()`, one asserting the
200 with it. If you only ever write the second, you have tested your test.
@@ -125,7 +125,7 @@ already in the past.
## Integration testing against the real server
`scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises
`jwt-authentication/scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises
a real Tomcat, a real HTTP client, real header parsing, and real base64url. Several
findings in these docs (the `resource_metadata` parameter, the `FACTOR_BEARER` authority,
the bare `WWW-Authenticate` on a wrapped `JwtException`) came from that script, not from

View File

@@ -5,14 +5,14 @@
Both are in this repository, behind profiles, secured identically. Run them side by side:
```bash
./scripts/run.sh hs256 # hand-written OncePerRequestFilter
./scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt()
./jwt-authentication/scripts/run.sh hs256 # hand-written OncePerRequestFilter
./jwt-authentication/scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt()
```
## The configuration, side by side
**Manual**
[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java):
[`SecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java):
```java
.addFilterBefore(new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint),
@@ -22,7 +22,7 @@ Both are in this repository, behind profiles, secured identically. Run them side
plus ~120 lines of filter, plus the entry point and access-denied handler wired by hand.
**Built-in**
[`ResourceServerSecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java):
[`ResourceServerSecurityConfig`](../jwt-authentication/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java):
```java
.oauth2ResourceServer(oauth2 -> oauth2

View File

@@ -1,6 +1,6 @@
# 11 — What changed in Spring Security 7
[← production checklist](10-production-checklist.md) · [README](../README.md)
[← production checklist](10-production-checklist.md) · [next: issuer and audience →](12-issuer-and-audience.md)
Everything below was hit while building this repository against Spring Security **7.1.1**
on Spring Boot **4.1.1**, JDK **25**. Verified by compiling or by reading real responses,

View File

@@ -0,0 +1,210 @@
# 12 — Issuer and audience: the two claims that make a token yours
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)
A valid signature proves the token was minted by something holding the signing key. It
proves nothing about *who it was minted for*. Those are two different questions, and a
resource server that only answers the first one is a resource server that will accept a
token issued to a different service in the same realm.
Everything here was captured from [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt),
produced by [`issuer-audience-demo.sh`](../oauth2-resource-server/scripts/issuer-audience-demo.sh).
## One property, three network calls
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/demo
```
That line makes Spring Boot build a `SupplierJwtDecoder`. The word that matters is
*supplier*: nothing happens at startup. On the **first token decoded**, three things occur
in order:
1. `GET {issuer-uri}/.well-known/openid-configuration` — the discovery document
2. The `issuer` value inside it is compared against your configured `issuer-uri`, and a
mismatch fails the whole decoder, not just one token
3. `GET {jwks_uri}` — the JWK Set, both to learn which algorithms the issuer signs with
and to get the keys themselves
The laziness is a feature. Your resource server starts even when the authorization server
is down; it fails on the first request instead of failing to boot. The cost is that the
first request after startup pays for two extra HTTP round trips, and any misconfiguration
in this chain shows up as a 500-flavoured `JwtDecoderInitializationException` on a request
rather than as a startup failure you would notice in a deployment pipeline.
Set `jwk-set-uri` **as well as** `issuer-uri` to skip step 1 and 2. You keep issuer
validation and lose discovery.
## `iss` is compared with `String.equals`
Not normalised. Not parsed as a URI. Compared.
```
iss = "http://localhost:9000/other"
HTTP 401
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid"
```
The token in that capture was signed by the correct key, by the same issuer, seconds
earlier. Only the string differed. A trailing slash is enough:
[`JwtValidationContractTests.issuerComparisonIsExactStringEquality`](../oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java)
pins that behaviour.
This is the single most common cause of *&ldquo;it works with curl but not from the
application&rdquo;* against Keycloak, because Keycloak derives `iss` from the request host
unless you pin `KC_HOSTNAME`. A token fetched through `localhost:8080` and a token fetched
through `keycloak:8080` inside a Docker network carry different issuers, and exactly one of
them matches your configuration. See [17 — Keycloak setup](17-keycloak-setup.md).
## `aud` is not checked at all by default
This is the part worth reading twice.
`JwtValidators.createDefaultWithIssuer(issuer)` — what Boot's auto-configuration uses when
you set only `issuer-uri` — builds a stack of `JwtTypeValidator`, `JwtTimestampValidator`,
`X509CertificateThumbprintValidator` and `JwtIssuerValidator`. There is no audience
validator in it.
So this happens:
```
iss = "http://localhost:9000" <- correct
aud = "billing-api" <- a different service entirely
HTTP 200
```
with the default configuration, and 401 once an audience check is added. Both transcripts
are in the repository: [`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) runs with
the `audience` profile and refuses it; the assertion that the *default* stack accepts it is
in `defaultStackDoesNotCheckAudience`.
Any of these three fixes it. They are ordered by how little you have to write.
```yaml
spring.security.oauth2.resourceserver.jwt.audiences: reports-api
```
```java
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtAudienceValidator("reports-api");
}
```
```java
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api")));
```
The second one deserves attention: Boot's `JwtDecoderConfiguration` collects **every**
`OAuth2TokenValidator<Jwt>` bean in the context and appends it to the stack. Adding a
validator does not mean replacing the decoder, and replacing the decoder is how people
accidentally lose the issuer validator they thought they still had.
Two details of `JwtAudienceValidator` that are easy to guess wrong, both pinned by tests:
- a token with `aud: ["billing-api", "reports-api"]` **passes** — it matches any entry,
not all of them
- a token with no `aud` claim at all is **refused**, not ignored
## The clock skew is 60 seconds and you will meet it
`JwtTimestampValidator` allows 60 seconds of skew by default in both directions. A token
that expired 30 seconds ago is accepted; one that expired 90 seconds ago is not:
```
4. Expired 90 seconds ago -> HTTP 401 "Jwt expired at ..."
5. Expired 30 seconds ago -> HTTP 200
```
That is usually what you want across machines whose clocks disagree. It is not what you
want if you are writing a test that asserts a token stops working the instant it expires,
and it is not what you want if your revocation story is *&ldquo;short-lived tokens&rdquo;*
your real worst case is the lifetime plus a minute.
To change it you have to build the validator yourself:
```java
new JwtTimestampValidator(Duration.ofSeconds(5))
```
## `typ=at+jwt` is refused by the default stack
RFC 9068 defines a media type for JWT access tokens and says an access token SHOULD carry
`typ: at+jwt` in its JOSE header. The default validator stack contains
`JwtTypeValidator.jwt()`, which accepts an **absent** `typ` or `typ=JWT`, and nothing else.
A conforming RFC 9068 access token therefore gets:
```
HTTP 401 error_description="... the given typ value needs to be one of [JWT]"
```
Keycloak is not affected, because its JOSE header says `typ: JWT` and it puts `Bearer` in a
*claim* of the same name, which nothing validates. An issuer that follows RFC 9068 more
closely will trip this. Two ways out:
```java
// accept the type explicitly
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
types.setAllowEmpty(true);
```
```java
// or validate the whole token as an RFC 9068 access token
decoder.setJwtValidator(JwtValidators.createAtJwtValidator()
.issuer(issuer).audience("reports-api").clientId("demo-client").build());
```
The second is stricter than it looks: `createAtJwtValidator()` also **requires** `exp`,
`sub`, `iat`, `jti` and `client_id` to be present. Keycloak does not emit `client_id` in an
access token, so this builder refuses Keycloak tokens until you tell it otherwise.
The two transcripts differing in exactly this one validator are
[`rs-issuer-audience.txt`](output/rs-issuer-audience.txt) and
[`rs-issuer-audience-attyp.txt`](output/rs-issuer-audience-attyp.txt).
## Where failures explain themselves
Nowhere in the response body. A resource server returns an empty body on 401 and puts the
reason in the `WWW-Authenticate` header, per RFC 6750:
```
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid",
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1",
resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
```
If you are debugging with a tool that hides response headers, every claim-validation
failure looks identical. That is worth knowing before you spend an afternoon on it.
## The endpoint you did not configure
That last `resource_metadata` parameter points at something new. Spring Security 7
publishes RFC 9728 protected resource metadata automatically and advertises it in the
challenge. It answers **without a token**, on a resource server whose chain says
`anyRequest().authenticated()`:
```
$ GET /.well-known/oauth-protected-resource
HTTP 200
{"resource":"http://localhost:8081","bearer_methods_supported":["header"],
"tls_client_certificate_bound_access_tokens":true}
```
It is standards-compliant and mostly harmless, but it is a new unauthenticated endpoint
that appears on upgrade, it confirms to an unauthenticated caller which authorization
server you trust, and it will show up in your next penetration test. Know that it is
there and that it is yours.
---
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)

127
docs/13-validator-stack.md Normal file
View File

@@ -0,0 +1,127 @@
# 13 — The validator stack
[← issuer and audience](12-issuer-and-audience.md) · [next: the authentication converter →](14-authentication-converter.md)
Signature verification and claim validation are two separate stages, run by two different
libraries. Nimbus verifies the signature; Spring Security validates the claims. Knowing
which stage refused a token tells you where to look.
```
BearerTokenAuthenticationFilter
└─ JwtAuthenticationProvider
└─ NimbusJwtDecoder.decode(token)
├─ 1. Nimbus DefaultJWTProcessor signature, alg, kid → key
└─ 2. Spring OAuth2TokenValidator iss, exp, nbf, aud, typ, ...
└─ JwtAuthenticationConverter claims → authorities (chapter 14)
```
Stage 1 failures read like `Signed JWT rejected: Another algorithm expected, or no matching
key(s) found`. Stage 2 failures name the claim: `The iss claim is not valid`. Both arrive as
`invalid_token` in the `WWW-Authenticate` header, so the description is the only thing that
distinguishes them.
## What is actually in the default stack
From `JwtValidators` in Spring Security 7.1.1:
| factory | contents |
|---|---|
| `createDefault()` | `JwtTypeValidator.jwt()`, `JwtTimestampValidator`, `X509CertificateThumbprintValidator` |
| `createDefaultWithIssuer(iss)` | the above plus `JwtIssuerValidator(iss)` |
| `createDefaultWithValidators(..)` | your validators, plus any of the three above you did not supply |
| `createAtJwtValidator()` | a builder for RFC 9068 access tokens |
Three things follow from that table.
**There is no audience validator.** Covered in [chapter 12](12-issuer-and-audience.md); it
is the most consequential omission in the list.
**`createDefaultWithValidators` adds, it does not replace.** Pass it a
`JwtTimestampValidator` with your own clock skew and it uses yours; pass it nothing of the
kind and it inserts the default. That is why supplying a custom timestamp validator works
without also having to re-supply the type and thumbprint validators.
**`createAtJwtValidator()` is much stricter than the name suggests.** Its builder
pre-populates required-claim validators for `exp`, `sub`, `iat`, `jti` and `client_id`, plus
a type validator restricted to `at+jwt` and `application/at+jwt`. A token missing any one of
those is refused. Reach for it when you control the issuer and it genuinely emits RFC 9068
access tokens — not as a general-purpose hardening switch.
## Two places a `typ` check can live
There are *two* independent type checks, and only one of them is on by default.
```java
NimbusJwtDecoder.withIssuerLocation(issuer)
.validateType(true) // Nimbus-level. Default: FALSE.
.build();
```
`validateType(boolean)` swaps Nimbus's `JOSEObjectTypeVerifier` between a no-op and one that
demands `typ=JWT`. It defaults to the no-op. Meanwhile the Spring-level
`JwtTypeValidator.jwt()` inside the default validator stack *is* present and *does* demand
`typ=JWT` or nothing. So the type is checked once, by Spring, on the way out.
Note the spelling. The reference documentation shows `validateTypes(false)`, plural. The
method on `JwkSetUriJwtDecoderBuilder` in 7.1.1 is **`validateType`**, singular. Reading the
docs and typing what they say does not compile.
## Adding a validator without losing the ones you have
The safe route, because it does not touch the decoder at all:
```java
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtAudienceValidator("reports-api");
}
```
Boot's `JwtDecoderConfiguration` gathers every such bean and appends it. The dangerous route
is the one the reference documentation demonstrates:
```java
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
decoder.setJwtValidator(new JwtAudienceValidator("reports-api")); // ← WRONG
return decoder;
}
```
`setJwtValidator` **replaces**. That decoder now checks the audience and nothing else — not
the issuer, not the expiry. It is a one-line downgrade that no test of the happy path will
catch, because the happy path still returns 200. Wrap with `createDefaultWithValidators` and
you keep everything:
```java
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api")));
```
## Pin the defaults in a test
The validator stack is exactly the kind of thing that changes between minor versions and
fails closed when it does — or worse, fails open. Nine assertions in
[`JwtValidationContractTests`](../oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java)
assert the *defaults* rather than this application's configuration, so that a Spring Security
upgrade that moves them turns a test red instead of turning a production check off:
```
defaultStackAcceptsAWellFormedToken
defaultStackDoesNotCheckAudience
addingJwtAudienceValidatorRefusesTheSameToken
defaultStackRefusesRfc9068AccessTokens
aPermissiveTypeValidatorAcceptsThem
issuerComparisonIsExactStringEquality
defaultClockSkewIsSixtySeconds
audienceValidatorMatchesAnyEntryNotAllOfThem
aMissingAudienceClaimIsRefusedNotIgnored
```
Run: `cd oauth2-resource-server && mvn test`. Result committed in
[`rs-test-run.txt`](output/rs-test-run.txt).
---
[← issuer and audience](12-issuer-and-audience.md) · [next: the authentication converter →](14-authentication-converter.md)

View File

@@ -0,0 +1,144 @@
# 14 — The authentication converter: from claims to authorities
[← the validator stack](13-validator-stack.md) · [next: JWKS caching and key rotation →](15-jwks-caching-and-rotation.md)
Validation decides whether a token is genuine. Conversion decides what it is allowed to do.
They fail differently: a validation failure is a **401** with a reason in
`WWW-Authenticate`; a conversion failure is a **403** with `insufficient_scope` and no
explanation of what was missing.
Three transcripts of the same token against the same endpoints, differing only in the
converter:
| profile | authorities produced | `/api/reports` | `/api/admin/stats` |
|---|---|---|---|
| [`stub`](output/rs-converter-default.txt) | `SCOPE_profile:read`, `SCOPE_reports:read` | 403 | 403 |
| [`stub,roles`](output/rs-converter-java.txt) | the above plus `ROLE_USER`, `ROLE_reports-reader` | 200 | 403 |
| [`stub,propsroles`](output/rs-converter-properties.txt) | `ROLE_USER`, `ROLE_reports-reader` | 200 | 403 |
`FACTOR_BEARER` appears in all three; it is a Spring Security 7 addition covered in
[chapter 11](11-spring-security-7-changes.md).
## What the default converter looks at
`JwtGrantedAuthoritiesConverter` reads the `scope` claim, or `scp` if `scope` is absent,
splits it on whitespace, and prefixes each value with `SCOPE_`. That is the whole algorithm.
Keycloak emits `scope`, so scopes work with no configuration. Roles do not, because Keycloak
puts them here:
```json
"realm_access": { "roles": ["USER"] },
"resource_access": { "reports-api": { "roles": ["reports-reader"] } }
```
Neither is the `scope` claim, so the default converter finds nothing, and every
`hasRole(..)` rule returns 403 against a token that authenticated perfectly. The first
transcript in the table above is that failure, and
`nestedKeycloakRolesAreInvisibleToTheDefaultAuthoritiesConverter` asserts it.
## Route one: configuration only
Spring Boot 4 added `authorities-claim-expressions`, a list of SpEL expressions evaluated
against the claim map. Nested claims need no Java:
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
principal-claim-name: preferred_username
authority-prefix: "ROLE_"
authorities-claim-expressions:
- "[realm_access][roles]"
- "[resource_access]['reports-api'][roles]"
```
**Quote the hyphenated client id.** Inside a SpEL indexer the contents are an expression,
not a literal key, so `[resource_access][reports-api][roles]` parses as `reports` minus
`api`:
```
[realm_access][roles] -> [USER]
[resource_access][reports-api][roles] -> SpelEvaluationException: EL1008E:
Property or field 'reports' cannot be found
[resource_access]['reports-api'][roles] -> [reports-reader]
```
And the failure does not reach you. `ExpressionJwtGrantedAuthoritiesConverter.getAuthorities`
catches `ExpressionException`, substitutes an empty list, and logs the reason at **TRACE**:
```java
catch (ExpressionException ee) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Failed to evaluate expression. error=%s", ee.getMessage()));
}
authorities = Collections.emptyList();
}
```
So a mistyped expression produces a 403, no exception, no WARN, and nothing in the log at
default levels. If a claim expression is not producing the authority you expect, the first
move is:
```yaml
logging.level.org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE
```
Two more limits of this route, both visible in the table above:
- **`authority-prefix` is a single value applied to every expression.** A mixed mapping —
`SCOPE_` for scopes and `ROLE_` for roles — cannot be expressed here.
- **Naming expressions replaces the default converter.** The `SCOPE_*` authorities are gone
from the `propsroles` row for exactly that reason. Adding `[scope]` as an expression brings
the values back, prefixed `ROLE_`, which is not what you meant.
`authorities-claim-expressions`, `authorities-claim-name` and `authorities-claim-delimiter`
are mutually exclusive; combining them throws
`MutuallyExclusiveConfigurationPropertiesException` at startup, which is the one failure in
this chapter that is loud.
## Route two: a `JwtAuthenticationConverter` bean
When the mapping is mixed, write it:
```java
@Bean
JwtAuthenticationConverter keycloakJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("preferred_username");
converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api"));
return converter;
}
```
Full source:
[`KeycloakAuthoritiesConfig`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/KeycloakAuthoritiesConfig.java).
It delegates scopes to the stock `JwtGrantedAuthoritiesConverter` and adds realm and client
roles with a `ROLE_` prefix, which is the mapping the middle row of the table produces.
**Defining this bean silently disables every one of the properties above.** Boot's
`JwtConverterConfiguration` is annotated
`@ConditionalOnMissingBean(JwtAuthenticationConverter.class)`, so the moment your bean
exists, `principal-claim-name`, `authority-prefix`, `authorities-claim-name`,
`authorities-claim-delimiter` and `authorities-claim-expressions` stop doing anything. No
warning is logged. If you have both, the YAML is decoration.
## Flattening namespaces has a cost
The converter here maps realm roles and client roles into one `ROLE_` namespace. That reads
well and matches what `hasRole(..)` expects, but if two clients in your realm each define a
role named `admin`, both collapse onto `ROLE_admin` and a token for one client passes a check
meant for the other. Prefix by client id if that is a real risk in your realm.
## Choosing
- Only scopes matter, and the issuer emits `scope` → change nothing.
- Roles from one nested claim, one prefix → properties.
- Two prefixes, filtering, a custom principal type, or anything conditional → a bean, and
delete the properties so nobody reads them and believes them.
---
[← the validator stack](13-validator-stack.md) · [next: JWKS caching and key rotation →](15-jwks-caching-and-rotation.md)

View File

@@ -0,0 +1,180 @@
# 15 — JWKS caching and key rotation
[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md)
Every guide says the same sentence: *&ldquo;Spring Security caches the JWK Set for five
minutes and rotates keys automatically.&rdquo;* It is half true, and the half that is not
decides whether a compromised key stops working in five minutes or never.
This chapter is what the code actually does, read from the Spring Security 7.1.1 and Nimbus
10.9.1 sources and then measured.
## The one method that decides everything
`NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()`:
```java
JWKSource<SecurityContext> jwkSource() {
String jwkSetUri = this.jwkSetUri.apply(this.restOperations);
return JWKSourceBuilder.create(new SpringJWKSource<>(this.restOperations, this.cache, jwkSetUri))
.refreshAheadCache(false)
.rateLimited(false)
.cache(this.cache instanceof NoOpCache)
.build();
}
```
Nimbus's own defaults, from `JWKSourceBuilder`:
| feature | Nimbus default | Spring Security 7.1.1 |
|---|---|---|
| `caching` | `true`, TTL 5 min, refresh timeout 15 s | only when no Spring cache was supplied |
| `refreshAhead` | `true`, 30 s ahead of expiry | **`false`** |
| `rateLimited` | `true`, min 30 s between forced refreshes | **`false`** |
| `outageTolerant` | `false` | `false` |
| `retrying` | `false` | `false` |
Two of Nimbus's protective defaults are switched off outright. The third line is the
surprising one: **supplying a Spring `Cache` switches Nimbus's cache off**, because
`this.cache instanceof NoOpCache` is false the moment you supply one. The reference
documentation recommends supplying a cache in order to share the JWK Set between instances,
and does not mention that doing so removes the five-minute expiry.
You do not have to take the source's word for it. `/api/public/decoder`
([`DecoderDiagnosticsController`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/web/DecoderDiagnosticsController.java))
walks the live object graph. From [`rs-decoder-chain.txt`](output/rs-decoder-chain.txt),
with a Caffeine cache supplied:
```json
"jwkSourceChain": [
{ "class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource" },
{ "class": "...NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource",
"jwkSetUri": "http://localhost:9000/jwks.json",
"springCache": "org.springframework.cache.caffeine.CaffeineCache",
"meaning": "a Spring cache was supplied, so Nimbus's cache layer was disabled and this cache's TTL is the only expiry" }
]
```
There is no `CachingJWKSetSource` in that chain. There is no `RateLimitedJWKSetSource`. Two
layers, and one of them is the HTTP call.
## What actually triggers a re-fetch
`JWKSetBasedJWKSource.get` is the whole rotation mechanism:
```java
JWKSet jwkSet = source.getJWKSet(JWKSetCacheRefreshEvaluator.noRefresh(), currentTime, context);
List<JWK> select = jwkSelector.select(jwkSet);
if (select.isEmpty()) {
JWKSet recentJwkSet = source.getJWKSet(JWKSetCacheRefreshEvaluator.referenceComparison(jwkSet), currentTime, context);
select = jwkSelector.select(recentJwkSet);
}
```
Select against the cached set; if nothing matches, force a refresh and select again. So a
JWK Set is re-fetched when, and only when:
1. a token arrives whose `kid` is not in the cached set, **or**
2. the cache expires — Nimbus's five-minute TTL if no Spring cache was supplied, your
cache's TTL if one was
With `refreshAhead(false)`, case 2 is always a synchronous refresh on a request thread. One
unlucky request every five minutes pays for the round trip to your identity provider.
## Rotation is three events, not one
Conflating them is where rotation incidents come from. The issuer in this repository fires
them separately on command, so a resource server can be watched in between. From
[`rs-rotation.txt`](output/rs-rotation.txt):
**Publish.** The new key appears in the JWK Set; nothing signs with it yet.
```
published: stub-key-2 publishedKids: [stub-key-1, stub-key-2]
Old token still works: 200
jwks fetches so far: 1 <- unchanged since the cold-start discovery fetch
```
The resource server has not noticed and has no reason to. This is the window an issuer is
supposed to leave, and its length is what makes rotation safe.
**Activate.** The issuer starts signing with the new key.
```
New token: 200
jwks fetches so far: 2 <- the unknown kid forced one
```
The first token with the new `kid` misses the cache, forces a refresh, and succeeds. Note
what that means: **the recovery is driven by the failure**. There is no scheduled refresh,
no background poll, no notification. If refresh-ahead were on it would matter less; it is
off.
**Retire.** The old key is removed from the JWK Set.
```
retired: stub-key-1 publishedKids: [stub-key-2]
Old token: 200 <- still accepted
jwks fetches so far: 2 <- nothing forced a refresh
```
Nothing changes for the resource server, because nothing forced it to look. How long a
retired key keeps working is decided entirely by the cache.
## How long a retired key keeps working
This matters when the reason for retiring is that the key leaked. The issuer removes it
immediately; the question is when your resource servers stop honouring tokens signed with
it.
The measurement is deliberately narrow: after the retirement the **only** traffic is the
leaked token itself. Its `kid` is in the stale cached set, so it never triggers the
unknown-kid refresh. Nothing else can dislodge the cache except its own expiry.
Two runs, identical but for the cache, in
[`rs-retired-key-default.txt`](output/rs-retired-key-default.txt) and
[`rs-retired-key-nottlcache.txt`](output/rs-retired-key-nottlcache.txt).
> **Read the two transcripts side by side.** They are the answer to
> &ldquo;how fast does revocation propagate&rdquo; for this stack, and the answer is
> different depending on one line of configuration you may have added for an unrelated
> reason.
The default configuration &mdash; no Spring cache &mdash; has Nimbus's `CachingJWKSetSource`
with `DEFAULT_CACHE_TIME_TO_LIVE = 5 * 60 * 1000L` in front of it. The set expires on its
own and the retired key goes with it.
A `ConcurrentMapCache` has no TTL. Neither does a `ConcurrentMapCacheManager`, which is what
Boot hands you when `spring-boot-starter-cache` is on the classpath and no cache provider
is configured. With one of those supplied, Nimbus's cache layer is off and nothing in the
system expires anything.
If you supply a cache, give it a TTL, and make that TTL a deliberate decision rather than an
inherited default:
```java
Cache cache = new CaffeineCache("jwks",
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build());
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).cache(cache).build();
```
## One more thing the Spring cache does
`SpringJWKSource.getJWKSet` calls `this.cache.invalidate()` — not `evict(key)` — when a
refresh is required. That clears **the entire cache**, not just the JWK Set entry. Give the
JWK Set its own dedicated cache; do not point it at a cache you share with anything else.
## What is not there
- **No outage tolerance.** `outageTolerant` is false. If the issuer is unreachable when the
cache expires, every request fails until it comes back. Nimbus offers
`OutageTolerantJWKSetSource`, which serves a stale set through an outage; Spring Security
does not wire it in and the builder gives you no way to ask for it short of building the
`JWKSource` yourself and passing it to `NimbusJwtDecoder.withJwkSource(..)`.
- **No retry.** A single failed HTTP call to the JWKS endpoint fails the request.
- **No rate limiting.** See [chapter 16](16-jwks-amplification.md), which is the sharpest
consequence of anything in this chapter.
---
[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md)

View File

@@ -0,0 +1,135 @@
# 16 — What an unknown `kid` costs your identity provider
[← JWKS caching and rotation](15-jwks-caching-and-rotation.md) · [next: Keycloak setup →](17-keycloak-setup.md)
Nimbus rate-limits forced JWK Set refreshes by default: `rateLimited = true`, with
`DEFAULT_RATE_LIMIT_MIN_INTERVAL = 30_000L`. Spring Security turns it off.
```java
JWKSourceBuilder.create(new SpringJWKSource<>(...))
.refreshAheadCache(false)
.rateLimited(false) // ← this line
.cache(this.cache instanceof NoOpCache)
.build();
```
With nothing between an unrecognised `kid` and the network, the refresh that
[chapter 15](15-jwks-caching-and-rotation.md) describes as the recovery mechanism becomes
something an attacker can drive.
## The measurement
[`amplification-demo.sh`](../oauth2-resource-server/scripts/amplification-demo.sh) sends 25
requests to the resource server and counts how many times the issuer's `/jwks.json` is
fetched. The issuer counts its own fetches, so this is not inferred from logs.
From [`rs-jwks-amplification.txt`](output/rs-jwks-amplification.txt):
```
Baseline: 25 requests with a VALID token, whose kid is in the cached JWK Set.
requests to the resource server : 25
fetches of /jwks.json : 0
Now 25 requests carrying a token whose kid has never existed.
requests to the resource server : 25
fetches of /jwks.json : 25
```
One to one. Every rejected request became an outbound HTTP request to the authorization
server, from a resource server that has not authenticated anybody.
The token itself is trivially cheap to make. It does not have to verify — it does not even
have to be signed by anything real. It only has to carry a `kid` the resource server has not
seen, which is a random string:
```
$ GET /api/me with an unknown kid
HTTP 401
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected:
Another algorithm expected, or no matching key(s) found"
```
Nothing in that response suggests anything unusual happened. The caller sees a 401. Your
identity provider sees the traffic.
It does not need an endpoint that requires authentication. The same 25 requests against
`/api/public/ping`, whose rule is `permitAll()`:
```
requests to /api/public/ping : 25
fetches of /jwks.json : 25
```
`BearerTokenAuthenticationFilter` runs before any authorization rule, so a public endpoint
still evaluates a bearer token when one is present. A broken token sent to a `permitAll()`
endpoint returns 401 from that public endpoint, and fetches the JWK Set on the way. Any
endpoint reachable without credentials is an entry point.
## Why this is worse than it first looks
- The amplification is **per resource server instance**, and every instance has its own
cache, so a fleet multiplies it.
- It is reachable through any endpoint at all, `permitAll()` ones included, because the
JWKS fetch happens during token decoding, before any authorization rule runs.
- Keycloak's JWKS endpoint is not usually the thing you capacity-plan for, and it sits in
front of the token endpoint that every one of your services depends on.
- The requests come from your own resource servers, which are on your identity provider's
allow-lists.
I have not found this documented anywhere as a consideration, and I would be glad to be
shown it is. What is certain is the behaviour: Nimbus defends against it, Spring Security
opts out, and the opt-out is one line in a package-private method with no property to
change it.
## What you can do
**Restore rate limiting.** Build the `JWKSource` yourself and hand it over. The builder does
not expose the toggle, but `withJwkSource` accepts a fully-built source:
```java
JWKSource<SecurityContext> source = JWKSourceBuilder
.<SecurityContext>create(new URI(jwkSetUri).toURL())
.cache(Duration.ofMinutes(5).toMillis(), Duration.ofSeconds(15).toMillis())
.refreshAheadCache(true)
.rateLimited(Duration.ofSeconds(30).toMillis())
.outageTolerant(Duration.ofMinutes(30).toMillis())
.retrying(true)
.build();
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSource(source).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator(issuer), new JwtAudienceValidator(audience)));
```
That is the `hardened` profile in
[`JwtDecoderConfig`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java),
so it compiles and runs rather than being a sketch. `new URL(String)` is deprecated for
removal on modern JDKs; `new URI(..).toURL()` is the replacement.
You lose three things: issuer discovery, so the JWK Set URI has to be configured explicitly;
the validator stack that `withIssuerLocation` supplied, which now has to be set in full; and
Spring's `RestOperations`, because `JWKSourceBuilder.create(URL)` fetches with Nimbus's own
`DefaultResourceRetriever` — any client customisation, proxy configuration or observability
wired into the Spring HTTP client no longer applies to JWKS fetches. In exchange you get
every protective layer Nimbus offers, which is more than the default gives you.
The trade is real: rate limiting means that during a genuine rotation, tokens signed with
the new key are refused for up to the rate-limit interval after the first miss. Thirty
seconds of 401s during a planned rotation, against an unbounded outbound request rate that
anyone can trigger. For most services that is the right way round, but it is a decision, not
a default.
**Rate-limit at the edge.** A limit on 401 responses per client is worth having anyway and
costs nothing here.
**Alert on the JWKS endpoint.** A fetch rate that tracks your request rate rather than your
instance count means this is happening. It is the cheapest detection available and most
people are not looking at that metric at all.
**Do not share one cache.** [Chapter 15](15-jwks-caching-and-rotation.md) covers
`cache.invalidate()` clearing everything; combined with this, a shared cache is repeatedly
emptied by unauthenticated traffic.
---
[← JWKS caching and rotation](15-jwks-caching-and-rotation.md) · [next: Keycloak setup →](17-keycloak-setup.md)

153
docs/17-keycloak-setup.md Normal file
View File

@@ -0,0 +1,153 @@
# 17 — Keycloak setup, and the three ways the realm import bites
[← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md)
```bash
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`](output/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:
```yaml
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](12-issuer-and-audience.md) explains why `JwtIssuerValidator` will refuse
one of them.
```yaml
environment:
KC_HOSTNAME: http://localhost:8080
KC_HOSTNAME_STRICT: "false"
```
This is the fix for the majority of *&ldquo;the token works in curl but not from the
application&rdquo;* 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](12-issuer-and-audience.md) argues you should be validating `aud`, you need a
mapper:
```json
{
"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:
```json
"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](14-authentication-converter.md) 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`](../oauth2-resource-server/docker/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:
```json
{"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](15-jwks-caching-and-rotation.md); a
surprise if you were expecting yesterday's tokens to still verify.
## What a real access token looks like here
```json
{
"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](14-authentication-converter.md):
```
"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](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md)

View File

@@ -0,0 +1,82 @@
# 18 — Resource server checklist
[← Keycloak setup](17-keycloak-setup.md) · [README](../README.md)
The list for a Spring Security resource server specifically. For the token-minting side, see
[10 — Production checklist](10-production-checklist.md).
## Claims
- [ ] **`aud` is validated.** It is not by default. One property, one bean, or one wrapped
validator — [12](12-issuer-and-audience.md). Without it, any token from your realm
works against any of your services.
- [ ] **`issuer-uri` matches the issuer string byte for byte**, from wherever the resource
server runs. Trailing slashes count. Pin `KC_HOSTNAME` — [17](17-keycloak-setup.md).
- [ ] **The clock skew is a decision.** 60 seconds by default, in both directions. If your
revocation story is short-lived tokens, the real worst case is the lifetime plus a
minute — [12](12-issuer-and-audience.md).
- [ ] **You know whether your issuer emits `typ: at+jwt`.** The default stack refuses it —
[12](12-issuer-and-audience.md).
- [ ] **`setJwtValidator` is never called with a bare validator.** It replaces the whole
stack. Wrap with `JwtValidators.createDefaultWithValidators` — [13](13-validator-stack.md).
- [ ] **The default stack is pinned by a test**, so an upgrade that moves it goes red rather
than quiet — [13](13-validator-stack.md).
## Authorities
- [ ] **Roles actually arrive.** Keycloak's live under `realm_access.roles` and
`resource_access.<client>.roles`; the default converter reads neither —
[14](14-authentication-converter.md).
- [ ] **Hyphenated client ids in SpEL expressions are quoted.** `['reports-api']`, not
`[reports-api]`. The failure is a silent empty authority list — [14](14-authentication-converter.md).
- [ ] **You have either a `JwtAuthenticationConverter` bean or the properties, not both.**
The bean silently disables the properties — [14](14-authentication-converter.md).
- [ ] **Realm and client roles that share a name do not collide** into one `ROLE_`
namespace in a way that grants something — [14](14-authentication-converter.md).
## JWKS and rotation
- [ ] **If you supplied a Spring `Cache`, it has a TTL.** Supplying one disables Nimbus's
five-minute cache; a `ConcurrentMapCache` never expires and a retired key stays
trusted — [15](15-jwks-caching-and-rotation.md).
- [ ] **The JWKS cache is not shared with anything else.** A refresh calls
`cache.invalidate()`, which clears the whole cache — [15](15-jwks-caching-and-rotation.md).
- [ ] **You know your revocation window.** It is the cache TTL, not zero, and not the token
lifetime — [15](15-jwks-caching-and-rotation.md).
- [ ] **Your issuer's rotation leaves a publish window** long enough for every resource
server to see the new key before it starts signing with it —
[15](15-jwks-caching-and-rotation.md).
- [ ] **Someone is watching the JWKS endpoint's request rate.** A rate that tracks request
volume rather than instance count means unknown-`kid` traffic is amplifying through
you — [16](16-jwks-amplification.md).
- [ ] **You have decided about rate limiting.** Spring Security disables Nimbus's. Restoring
it means building the `JWKSource` yourself and giving up discovery —
[16](16-jwks-amplification.md).
- [ ] **You have decided about outage tolerance.** It is off. When the cache expires and the
issuer is unreachable, every request fails — [15](15-jwks-caching-and-rotation.md).
## Endpoints
- [ ] **`/api/public/decoder` is deleted.** The diagnostic in this repository reveals your
JWK Set URI and cache timings to anyone who can reach it.
- [ ] **You know `/.well-known/oauth-protected-resource` exists.** Spring Security 7
publishes it, unauthenticated, without being asked — [12](12-issuer-and-audience.md).
- [ ] **CSRF is disabled deliberately**, because a stateless bearer-token API has no
ambient credential to protect — and for no other reason
— [04](04-csrf-permitall-403.md).
## Should you build this at all
If your services are minting their own tokens for their own users, you do not need a
resource server and you do not need an identity provider; the hand-written filter in
[`jwt-authentication/`](../jwt-authentication) is less code and fewer moving parts. See
[09 — manual filter vs resource server](09-manual-filter-vs-resource-server.md).
The resource server earns its complexity when tokens come from somewhere you do not control:
several services trusting one issuer, an identity provider you did not write, key rotation
that has to happen without redeploying anything. If that is not your situation, most of this
document is a list of ways to get something wrong that you could simply not have.
---
[← Keycloak setup](17-keycloak-setup.md) · [README](../README.md)

View File

@@ -0,0 +1,66 @@
--------------------------------------------------------------------------
resource server profiles: stub
--------------------------------------------------------------------------
--------------------------------------------------------------------------
1. alice - realm role USER, client role reports-reader
--------------------------------------------------------------------------
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me (which authorities did the converter produce?)
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:33:03Z"
}
$ GET /api/reports (needs ROLE_reports-reader, from resource_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
2. root - realm roles USER and ADMIN
--------------------------------------------------------------------------
$ GET /api/me
HTTP 200
{
"name": "root",
"authorities": [
"FACTOR_BEARER",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:33:04Z"
}
$ GET /api/admin/stats
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

View File

@@ -0,0 +1,77 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles
--------------------------------------------------------------------------
--------------------------------------------------------------------------
1. alice - realm role USER, client role reports-reader
--------------------------------------------------------------------------
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me (which authorities did the converter produce?)
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:33:11Z"
}
$ GET /api/reports (needs ROLE_reports-reader, from resource_access)
HTTP 200
{
"reports": 3
}
$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
2. root - realm roles USER and ADMIN
--------------------------------------------------------------------------
$ GET /api/me
HTTP 200
{
"name": "root",
"authorities": [
"FACTOR_BEARER",
"ROLE_ADMIN",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:33:11Z"
}
$ GET /api/admin/stats
HTTP 200
{
"secret": "only ROLE_ADMIN sees this"
}

View File

@@ -0,0 +1,77 @@
--------------------------------------------------------------------------
resource server profiles: stub,propsroles-broken,tracespel
--------------------------------------------------------------------------
--------------------------------------------------------------------------
1. alice - realm role USER, client role reports-reader
--------------------------------------------------------------------------
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me (which authorities did the converter produce?)
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T11:00:53Z"
}
$ GET /api/reports (needs ROLE_reports-reader, from resource_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
2. root - realm roles USER and ADMIN
--------------------------------------------------------------------------
$ GET /api/me
HTTP 200
{
"name": "root",
"authorities": [
"FACTOR_BEARER",
"ROLE_ADMIN",
"ROLE_USER"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T11:00:53Z"
}
$ GET /api/admin/stats
HTTP 200
{
"secret": "only ROLE_ADMIN sees this"
}
--------------------------------------------------------------------------
what the resource server logged, at TRACE, while producing that 403
--------------------------------------------------------------------------
Failed to evaluate expression. error=EL1008E: Property or field 'reports' cannot be found on object of type 'java.util.Collections$UnmodifiableMap' - maybe not public or not valid?
Found authorities with expression. authorities=[USER, ADMIN]
Found authorities with expression. authorities=[USER]
Looking for authorities with expression. expression=[realm_access][roles]
Looking for authorities with expression. expression=[resource_access][reports-api][roles]

View File

@@ -0,0 +1,68 @@
--------------------------------------------------------------------------
resource server profiles: stub,propsroles
--------------------------------------------------------------------------
--------------------------------------------------------------------------
1. alice - realm role USER, client role reports-reader
--------------------------------------------------------------------------
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me (which authorities did the converter produce?)
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:33:19Z"
}
$ GET /api/reports (needs ROLE_reports-reader, from resource_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
$ GET /api/admin/stats (needs ROLE_ADMIN, from realm_access)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
2. root - realm roles USER and ADMIN
--------------------------------------------------------------------------
$ GET /api/me
HTTP 200
{
"name": "root",
"authorities": [
"FACTOR_BEARER",
"ROLE_ADMIN",
"ROLE_USER"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:33:19Z"
}
$ GET /api/admin/stats
HTTP 200
{
"secret": "only ROLE_ADMIN sees this"
}

View File

@@ -0,0 +1,107 @@
--------------------------------------------------------------------------
resource server profiles: stub
--------------------------------------------------------------------------
One token is decoded first, because the decoder for an issuer-uri is built lazily.
{
"decoderClass": "org.springframework.security.oauth2.jwt.SupplierJwtDecoder",
"note": "SupplierJwtDecoder: built lazily on first decode, then cached",
"resolvedDecoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder",
"processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor",
"keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector",
"jwkSourceChain": [
{
"class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource"
},
{
"class": "com.nimbusds.jose.jwk.source.CachingJWKSetSource",
"timeToLiveMs": 300000,
"cacheRefreshTimeoutMs": 15000,
"meaning": "the JWK Set is re-fetched no more often than timeToLive"
},
{
"class": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource",
"jwkSetUri": "http://localhost:9000/jwks.json",
"springCache": "org.springframework.cache.support.NoOpCache",
"meaning": "no Spring cache supplied, so Nimbus's own cache layer is enabled above"
}
],
"readMe": "Each entry wraps the next. A layer that is absent was switched off."
}
--------------------------------------------------------------------------
resource server profiles: stub,springcache
--------------------------------------------------------------------------
One token is decoded first, because the decoder for an issuer-uri is built lazily.
{
"decoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder",
"processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor",
"keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector",
"jwkSourceChain": [
{
"class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource"
},
{
"class": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource",
"jwkSetUri": "http://localhost:9000/jwks.json",
"springCache": "org.springframework.cache.caffeine.CaffeineCache",
"meaning": "a Spring cache was supplied, so Nimbus's cache layer was disabled and this cache's TTL is the only expiry"
}
],
"readMe": "Each entry wraps the next. A layer that is absent was switched off."
}
--------------------------------------------------------------------------
resource server profiles: stub,nottlcache
--------------------------------------------------------------------------
One token is decoded first, because the decoder for an issuer-uri is built lazily.
{
"decoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder",
"processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor",
"keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector",
"jwkSourceChain": [
{
"class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource"
},
{
"class": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder$SpringJWKSource",
"jwkSetUri": "http://localhost:9000/jwks.json",
"springCache": "org.springframework.cache.concurrent.ConcurrentMapCache",
"meaning": "a Spring cache was supplied, so Nimbus's cache layer was disabled and this cache's TTL is the only expiry"
}
],
"readMe": "Each entry wraps the next. A layer that is absent was switched off."
}
--------------------------------------------------------------------------
resource server profiles: stub,hardened
--------------------------------------------------------------------------
One token is decoded first, because the decoder for an issuer-uri is built lazily.
{
"decoderClass": "org.springframework.security.oauth2.jwt.NimbusJwtDecoder",
"processor": "com.nimbusds.jwt.proc.DefaultJWTProcessor",
"keySelector": "com.nimbusds.jose.proc.JWSVerificationKeySelector",
"jwkSourceChain": [
{
"class": "com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource"
},
{
"class": "com.nimbusds.jose.jwk.source.RefreshAheadCachingJWKSetSource",
"timeToLiveMs": 300000,
"cacheRefreshTimeoutMs": 15000,
"meaning": "the JWK Set is re-fetched no more often than timeToLive"
},
{
"class": "com.nimbusds.jose.jwk.source.RateLimitedJWKSetSource",
"minTimeIntervalMs": 30000,
"meaning": "forced refreshes are throttled to this interval"
},
{
"class": "com.nimbusds.jose.jwk.source.OutageTolerantJWKSetSource",
"meaning": "a stale JWK Set is served if the issuer is unreachable"
},
{
"class": "com.nimbusds.jose.jwk.source.RetryingJWKSetSource"
},
{
"class": "com.nimbusds.jose.jwk.source.URLBasedJWKSetSource"
}
],
"readMe": "Each entry wraps the next. A layer that is absent was switched off."
}

View File

@@ -0,0 +1,160 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles,audience,attyp
--------------------------------------------------------------------------
--------------------------------------------------------------------------
1. A correct token
--------------------------------------------------------------------------
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:32:56Z"
}
--------------------------------------------------------------------------
2. Signed by the right key, but iss says something else
--------------------------------------------------------------------------
The signature verifies. The key is the same key. Only the string differs.
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000/other"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
3. A token minted for a different service in the same realm
--------------------------------------------------------------------------
This is the one that silently works when nothing checks aud.
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "billing-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
4. Expired 90 seconds ago
--------------------------------------------------------------------------
The default clock skew is 60s, so a token has to be more than a minute stale
before JwtTimestampValidator refuses it.
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-08-23T10:26:26Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
5. Expired 30 seconds ago - inside the default clock skew
--------------------------------------------------------------------------
$ GET /api/me
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:27:26Z"
}
--------------------------------------------------------------------------
6. typ=at+jwt, which RFC 9068 says an access token SHOULD carry
--------------------------------------------------------------------------
The default validator stack contains JwtTypeValidator.jwt(), which accepts only an
absent typ or typ=JWT. Whether this passes depends on the attyp profile.
header: {"kid": "stub-key-1", "typ": "at+jwt", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "at+jwt",
"kid": "stub-key-1",
"exp": "2026-08-23T10:32:56Z"
}
--------------------------------------------------------------------------
7. No token at all
--------------------------------------------------------------------------
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
$ GET /api/public/ping
HTTP 200
{
"status": "up"
}
--------------------------------------------------------------------------
8. The endpoint nobody configured
--------------------------------------------------------------------------
Spring Security 7 publishes RFC 9728 protected resource metadata and points the
WWW-Authenticate challenge at it. It answers without a token.
$ GET /.well-known/oauth-protected-resource
HTTP 200
{
"resource": "http://localhost:8081",
"bearer_methods_supported": [
"header"
],
"tls_client_certificate_bound_access_tokens": true
}

View File

@@ -0,0 +1,143 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles,audience
--------------------------------------------------------------------------
--------------------------------------------------------------------------
1. A correct token
--------------------------------------------------------------------------
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:32:48Z"
}
--------------------------------------------------------------------------
2. Signed by the right key, but iss says something else
--------------------------------------------------------------------------
The signature verifies. The key is the same key. Only the string differs.
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000/other"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
3. A token minted for a different service in the same realm
--------------------------------------------------------------------------
This is the one that silently works when nothing checks aud.
header: {"kid": "stub-key-1", "typ": "JWT", "alg": "RS256"}
iss "http://localhost:9000"
aud "billing-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
4. Expired 90 seconds ago
--------------------------------------------------------------------------
The default clock skew is 60s, so a token has to be more than a minute stale
before JwtTimestampValidator refuses it.
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-08-23T10:26:18Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
5. Expired 30 seconds ago - inside the default clock skew
--------------------------------------------------------------------------
$ GET /api/me
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_profile:read",
"SCOPE_reports:read"
],
"iss": "http://localhost:9000",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "stub-key-1",
"exp": "2026-08-23T10:27:18Z"
}
--------------------------------------------------------------------------
6. typ=at+jwt, which RFC 9068 says an access token SHOULD carry
--------------------------------------------------------------------------
The default validator stack contains JwtTypeValidator.jwt(), which accepts only an
absent typ or typ=JWT. Whether this passes depends on the attyp profile.
header: {"kid": "stub-key-1", "typ": "at+jwt", "alg": "RS256"}
iss "http://localhost:9000"
aud "reports-api"
scope "profile:read reports:read"
preferred_username "alice"
realm_access {"roles": ["USER"]}
resource_access {"reports-api": {"roles": ["reports-reader"]}}
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
7. No token at all
--------------------------------------------------------------------------
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
$ GET /api/public/ping
HTTP 200
{
"status": "up"
}
--------------------------------------------------------------------------
8. The endpoint nobody configured
--------------------------------------------------------------------------
Spring Security 7 publishes RFC 9728 protected resource metadata and points the
WWW-Authenticate challenge at it. It answers without a token.
$ GET /.well-known/oauth-protected-resource
HTTP 200
{
"resource": "http://localhost:8081",
"bearer_methods_supported": [
"header"
],
"tls_client_certificate_bound_access_tokens": true
}

View File

@@ -0,0 +1,34 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles
--------------------------------------------------------------------------
Baseline: 25 requests with a VALID token, whose kid is in the cached JWK Set.
requests to the resource server : 25
fetches of /jwks.json : 0
Now 25 requests carrying a token whose kid has never existed.
Each one is refused - but look at what it costs the issuer first.
requests to the resource server : 25
fetches of /jwks.json : 25
Every rejected request became a request to the authorization server. An attacker who
can reach an unauthenticated endpoint of your resource server can point that ratio at
your identity provider, from one connection, using tokens that are never valid.
And it does not need an authenticated endpoint. /api/public/ping is permitAll().
requests to /api/public/ping : 25
fetches of /jwks.json : 25
A permitAll() endpoint still evaluates a bearer token if one is present, because
BearerTokenAuthenticationFilter runs before any authorization rule. Presenting a
broken token to a public endpoint gets a 401 from the public endpoint - and a
JWKS fetch on the way.
$ GET /api/public/ping with an unknown kid
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Another algorithm expected, or no matching key(s) found", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
The status returned to the caller is unremarkable:
$ GET /api/me with an unknown kid
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Another algorithm expected, or no matching key(s) found", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"

View File

@@ -0,0 +1,71 @@
--------------------------------------------------------------------------
Keycloak discovery document
--------------------------------------------------------------------------
issuer http://localhost:8080/realms/demo
jwks_uri http://localhost:8080/realms/demo/protocol/openid-connect/certs
token_endpoint http://localhost:8080/realms/demo/protocol/openid-connect/token
--------------------------------------------------------------------------
Keycloak JWK Set
--------------------------------------------------------------------------
keys published: 2
kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA
kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA
--------------------------------------------------------------------------
1. alice, password grant
--------------------------------------------------------------------------
header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"}
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"]}}
$ GET /api/me
HTTP 200
{
"name": "79448051-2590-4dfe-8374-314fcf2d7273",
"authorities": [
"FACTOR_BEARER",
"SCOPE_email",
"SCOPE_profile"
],
"iss": "http://localhost:8080/realms/demo",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE",
"exp": "2026-08-23T10:39:01Z"
}
$ GET /api/reports (client role, from resource_access.reports-api.roles)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
$ GET /api/admin/stats (realm role ADMIN, which alice does not have)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
2. root
--------------------------------------------------------------------------
$ GET /api/admin/stats
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
3. nobody - a user with no client role
--------------------------------------------------------------------------
header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"}
iss "http://localhost:8080/realms/demo"
aud "reports-api"
typ "Bearer"
scope "email profile"
preferred_username "nobody"
realm_access {"roles": ["USER"]}
$ GET /api/reports
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

View File

@@ -0,0 +1,79 @@
--------------------------------------------------------------------------
Keycloak discovery document
--------------------------------------------------------------------------
issuer http://localhost:8080/realms/demo
jwks_uri http://localhost:8080/realms/demo/protocol/openid-connect/certs
token_endpoint http://localhost:8080/realms/demo/protocol/openid-connect/token
--------------------------------------------------------------------------
Keycloak JWK Set
--------------------------------------------------------------------------
keys published: 2
kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA
kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA
--------------------------------------------------------------------------
1. alice, password grant
--------------------------------------------------------------------------
header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"}
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"]}}
$ GET /api/me
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"ROLE_reports-reader",
"SCOPE_email",
"SCOPE_profile"
],
"iss": "http://localhost:8080/realms/demo",
"aud": [
"reports-api"
],
"typ": "JWT",
"kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE",
"exp": "2026-08-23T10:38:53Z"
}
$ GET /api/reports (client role, from resource_access.reports-api.roles)
HTTP 200
{
"reports": 3
}
$ GET /api/admin/stats (realm role ADMIN, which alice does not have)
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
2. root
--------------------------------------------------------------------------
$ GET /api/admin/stats
HTTP 200
{
"secret": "only ROLE_ADMIN sees this"
}
--------------------------------------------------------------------------
3. nobody - a user with no client role
--------------------------------------------------------------------------
header: {"alg": "RS256", "typ": "JWT", "kid": "B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE"}
iss "http://localhost:8080/realms/demo"
aud "reports-api"
typ "Bearer"
scope "email profile"
preferred_username "nobody"
realm_access {"roles": ["USER"]}
$ GET /api/reports
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

View File

@@ -0,0 +1,47 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles
--------------------------------------------------------------------------
Scenario: a signing key is compromised. The issuer publishes a replacement and
removes the compromised key from the JWK Set immediately. Tokens it signed are
already out there with an hour left to run.
1. A token signed with stub-key-1, one hour to live: GET /api/me -> 200
jwks fetches: 1
2. Issuer rotates to stub-key-2 and retires stub-key-1.
{
"message": "ok",
"activeKid": "stub-key-2",
"publishedKids": [
"stub-key-2"
],
"jwksFetches": 1
}
Anyone fetching /jwks.json from this moment sees only stub-key-2.
3. From here the ONLY traffic is the leaked token. Nothing carries an unknown kid,
so nothing forces a refresh. Whether the token keeps working is decided purely
by whether the cached JWK Set expires.
elapsed leaked jwksFetches
t+0s 200 1
t+30s 200 1
t+60s 200 1
t+90s 200 1
t+120s 200 1
t+150s 200 1
t+180s 200 1
t+210s 200 1
t+240s 200 1
t+270s 200 1
t+300s 401 3
t+330s 401 4
t+360s 401 5
t+390s 401 6
t+420s 401 7
t+450s 401 8
A row that flips to 401 is the cache expiring and the retired key going away.
A column of 200s is a resource server that has not noticed, and will not, until
something happens to bring it a token it cannot verify.

View File

@@ -0,0 +1,47 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles,nottlcache
--------------------------------------------------------------------------
Scenario: a signing key is compromised. The issuer publishes a replacement and
removes the compromised key from the JWK Set immediately. Tokens it signed are
already out there with an hour left to run.
1. A token signed with stub-key-1, one hour to live: GET /api/me -> 200
jwks fetches: 0
2. Issuer rotates to stub-key-2 and retires stub-key-1.
{
"message": "ok",
"activeKid": "stub-key-2",
"publishedKids": [
"stub-key-2"
],
"jwksFetches": 0
}
Anyone fetching /jwks.json from this moment sees only stub-key-2.
3. From here the ONLY traffic is the leaked token. Nothing carries an unknown kid,
so nothing forces a refresh. Whether the token keeps working is decided purely
by whether the cached JWK Set expires.
elapsed leaked jwksFetches
t+0s 200 0
t+30s 200 0
t+60s 200 0
t+90s 200 0
t+120s 200 0
t+150s 200 0
t+180s 200 0
t+210s 200 0
t+240s 200 0
t+270s 200 0
t+300s 200 0
t+330s 200 0
t+360s 200 0
t+390s 200 0
t+420s 200 0
t+450s 200 0
A row that flips to 401 is the cache expiring and the retired key going away.
A column of 200s is a resource server that has not noticed, and will not, until
something happens to bring it a token it cannot verify.

View File

@@ -0,0 +1,69 @@
--------------------------------------------------------------------------
resource server profiles: stub,roles,audience
--------------------------------------------------------------------------
Issuer state at the start:
{
"message": "ok",
"activeKid": "stub-key-1",
"publishedKids": [
"stub-key-1"
],
"jwksFetches": 0
}
--------------------------------------------------------------------------
0. Warm the cache
--------------------------------------------------------------------------
token signed with stub-key-1
GET /api/me -> 200
jwks fetches so far: 1
--------------------------------------------------------------------------
1. PUBLISH a second key. Nothing signs with it yet.
--------------------------------------------------------------------------
published: stub-key-2
{
"message": "ok",
"activeKid": "stub-key-1",
"publishedKids": [
"stub-key-1",
"stub-key-2"
],
"jwksFetches": 1
}
The resource server has not been told. Its cached JWK Set still holds one key.
Old token still works: 200
jwks fetches so far: 1 <- unchanged: nothing forced a refresh
--------------------------------------------------------------------------
2. ACTIVATE the new key. The issuer starts signing with it.
--------------------------------------------------------------------------
A token arrives whose kid is not in the cached JWK Set.
New token: 200
jwks fetches so far: 2 <- the unknown kid forced one
This is the recovery path, and it works. It is also the only thing in the default
configuration that notices a rotation, because refresh-ahead is switched off.
--------------------------------------------------------------------------
3. Tokens signed with the old key are still in flight
--------------------------------------------------------------------------
They were minted before the switch and have not expired yet.
Old token: 200 <- still accepted, because the old key is still published
--------------------------------------------------------------------------
4. RETIRE the old key from the JWK Set
--------------------------------------------------------------------------
retired: stub-key-1
{
"message": "ok",
"activeKid": "stub-key-2",
"publishedKids": [
"stub-key-2"
],
"jwksFetches": 2
}
The resource server's cache still contains it, so nothing changes yet.
Old token: 200
New token: 200
jwks fetches so far: 2
How long the old key keeps working from here is decided entirely by the cache.
See retired-key-demo.sh, which runs this same step under two cache configurations.

View File

@@ -0,0 +1,13 @@
==========================================================================
oauth2-resource-server-demo - test run
==========================================================================
Running com.ankurm.rsdemo.JwtValidationContractTests
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.288 s -- in com.ankurm.rsdemo.JwtValidationContractTests
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
JDK : openjdk version "25.0.4.1" 2026-08-18 LTS
Boot : 4.1.1
Security : 7.1.1
Nimbus : 10.9.1