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

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:

  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:

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. 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, which is the sharpest consequence of anything in this chapter.

← the authentication converter · next: what an unknown kid costs →