# 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: *“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()`: ```java JWKSource 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 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 > “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: ```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)