1
0
Files
spring-auth-demo/docs/15-jwks-caching-and-rotation.md
Ankur Mhatre 38c0a5f358 Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
2026-08-24 08:12:36 +05:30

189 lines
8.2 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
> &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)
---
The provider side of rotation &mdash; generating, publishing and retiring the keys this
chapter watches from the outside &mdash; is
[`authorization-server/02`](authorization-server/02-minimum-provider.md), and why a demo
provider regenerating its keypair per boot is a feature rather than a bug is in
[`authorization-server/10`](authorization-server/10-should-you.md).