1
0
Files
jwt-auth-demo/docs/15-jwks-caching-and-rotation.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

181 lines
7.8 KiB
Markdown

# 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)