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
7.8 KiB
15 — JWKS caching and key rotation
← the authentication converter · next: what an unknown kid costs →
Every guide says the same sentence: “Spring Security caches the JWK Set for five minutes and rotates keys automatically.” 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():
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)
walks the live object graph. From rs-decoder-chain.txt,
with a Caffeine cache supplied:
"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:
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:
- a token arrives whose
kidis not in the cached set, or - 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:
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 and
rs-retired-key-nottlcache.txt.
Read the two transcripts side by side. They are the answer to “how fast does revocation propagate” 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 — no Spring cache — 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:
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.
outageTolerantis false. If the issuer is unreachable when the cache expires, every request fails until it comes back. Nimbus offersOutageTolerantJWKSetSource, 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 theJWKSourceyourself and passing it toNimbusJwtDecoder.withJwkSource(..). - No retry. A single failed HTTP call to the JWKS endpoint fails the request.
- No rate limiting. See chapter 16, which is the sharpest consequence of anything in this chapter.
← the authentication converter · next: what an unknown kid costs →