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.
8.2 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 →
The provider side of rotation — generating, publishing and retiring the keys this
chapter watches from the outside — is
authorization-server/02, and why a demo
provider regenerating its keypair per boot is a feature rather than a bug is in
authorization-server/10.