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
181 lines
7.8 KiB
Markdown
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: *“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<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
|
|
> “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)
|