Expected Behavior
JwkSetUriJwtDecoderBuilder should let me reach the JWKSourceBuilder features it builds on — refresh-ahead caching, cache TTL, outage tolerance, rate limiting — e.g. a
jwkSourceBuilderCustomizer(Consumer<JWKSourceBuilder<SecurityContext>>) alongside the existing jwtProcessorCustomizer.
I'd go further: refresh-ahead caching and outage tolerance are Nimbus's own defaults in JWKSourceBuilder, and I'd argue they are the right defaults here too. A key set is needed for every request, changes a few times a year,
and the fetch runs on a user request thread while SpringJWKSource holds a ReentrantLock — so refreshing ahead of expiry and tolerating a failed refetch is what most resource servers want. Today all of it is switched off,
and none of it is configurable.
Current Behavior
jwkSource() hardcodes .refreshAheadCache(false).rateLimited(false) and installs Nimbus's cache only when no Cache was supplied (7.1.1):
JWKSourceBuilder.create(new SpringJWKSource<>(this.restOperations, this.cache, jwkSetUri))
.refreshAheadCache(false)
.rateLimited(false)
.cache(this.cache instanceof NoOpCache)
.build();
With no Cache set — the default — that means JWKSourceBuilder.DEFAULT_CACHE_TIME_TO_LIVE of 5 minutes with no way to change it: roughly 288 blocking fetches per day per instance, each paid by whichever user request finds
the entry expired. Supplying a Cache fixes the TTL, but refresh-ahead and outage tolerance stay unreachable: a JwkSetUriJwtDecoderBuilderCustomizer has no seam for them and SpringJWKSource is private.
#17226 asked for this and was closed as a duplicate of #17046. #17046 added NimbusJwtDecoder.withJwkSource(...), which doesn't cover it: JwkSourceJwtDecoderBuilder takes no jwkSetUri, so that route means giving up OIDC
discovery of the key set URI (JwtDecoderProviderConfigurationUtils is package private) and the issuer/audience validator wiring that Spring Boot's auto-configuration does for me.
Context
Azure Entra as the provider, two AWS environments, Spring Boot 4.1 / Spring Security 7.1.1. In 14 days of logs: ~8000 JWK Set fetches on the 5-minute grid, 49 of them failing with a transient java.net.SocketException: Connection reset during the TLS handshake. A failure on an expired entry isn't a 401 — it becomes an AuthenticationServiceException, which AuthenticationEntryPointFailureHandler rethrows, so it surfaces as an HTTP 500.
Alternatives I tried:
builder.cache(caffeineCache) plus builder.restOperations(apacheHttpClient5). Gets me a 6 hour TTL and one retry, but no background refresh, and outage tolerance only through a Cache subclass that swallows
ValueRetrievalException — which works solely because SpringJWKSource.getJWKSet returns its own field rather than the cache's value.
NimbusJwtDecoder.withJwkSource(...) with a properly layered Nimbus source: around 230 lines of reimplemented discovery and validator wiring to get one feature.
Both are workarounds for something the underlying library already does.
Expected Behavior
JwkSetUriJwtDecoderBuildershould let me reach theJWKSourceBuilderfeatures it builds on — refresh-ahead caching, cache TTL, outage tolerance, rate limiting — e.g. ajwkSourceBuilderCustomizer(Consumer<JWKSourceBuilder<SecurityContext>>)alongside the existingjwtProcessorCustomizer.I'd go further: refresh-ahead caching and outage tolerance are Nimbus's own defaults in
JWKSourceBuilder, and I'd argue they are the right defaults here too. A key set is needed for every request, changes a few times a year,and the fetch runs on a user request thread while
SpringJWKSourceholds aReentrantLock— so refreshing ahead of expiry and tolerating a failed refetch is what most resource servers want. Today all of it is switched off,and none of it is configurable.
Current Behavior
jwkSource()hardcodes.refreshAheadCache(false).rateLimited(false)and installs Nimbus's cache only when noCachewas supplied (7.1.1):With no
Cacheset — the default — that meansJWKSourceBuilder.DEFAULT_CACHE_TIME_TO_LIVEof 5 minutes with no way to change it: roughly 288 blocking fetches per day per instance, each paid by whichever user request findsthe entry expired. Supplying a
Cachefixes the TTL, but refresh-ahead and outage tolerance stay unreachable: aJwkSetUriJwtDecoderBuilderCustomizerhas no seam for them andSpringJWKSourceis private.#17226 asked for this and was closed as a duplicate of #17046. #17046 added
NimbusJwtDecoder.withJwkSource(...), which doesn't cover it:JwkSourceJwtDecoderBuildertakes nojwkSetUri, so that route means giving up OIDCdiscovery of the key set URI (
JwtDecoderProviderConfigurationUtilsis package private) and the issuer/audience validator wiring that Spring Boot's auto-configuration does for me.Context
Azure Entra as the provider, two AWS environments, Spring Boot 4.1 / Spring Security 7.1.1. In 14 days of logs: ~8000 JWK Set fetches on the 5-minute grid, 49 of them failing with a transient
java.net.SocketException: Connection resetduring the TLS handshake. A failure on an expired entry isn't a 401 — it becomes anAuthenticationServiceException, whichAuthenticationEntryPointFailureHandlerrethrows, so it surfaces as an HTTP 500.Alternatives I tried:
builder.cache(caffeineCache)plusbuilder.restOperations(apacheHttpClient5). Gets me a 6 hour TTL and one retry, but no background refresh, and outage tolerance only through aCachesubclass that swallowsValueRetrievalException— which works solely becauseSpringJWKSource.getJWKSetreturns its own field rather than the cache's value.NimbusJwtDecoder.withJwkSource(...)with a properly layered Nimbus source: around 230 lines of reimplemented discovery and validator wiring to get one feature.Both are workarounds for something the underlying library already does.