Skip to main content

Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation

Spring Boot 4.1 and Spring Security 7.1 make a resource server one property long. That property does not validate the audience, cannot see Keycloak’s roles, and caches the JWK Set in a way that decides whether a leaked signing key stops working in five minutes or never. Read from the sources, then measured: a retired key accepted indefinitely, and 25 bad tokens producing 25 JWKS fetches at the issuer.

You have a Spring Boot service that used to mint its own JWTs and check them with a filter you wrote. Now the tokens come from Keycloak, or Entra ID, or Auth0, and your service is supposed to just trust them. The migration looks like a deletion: the login endpoint goes, the token service goes, the filter goes, and one property arrives.
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/demo
That works. A token from the right issuer returns 200, a forged one returns 401, and it is tempting to stop there. Three things are not what the shape of that property suggests. The token minted for a different service in the same realm also returns 200, because the audience is not checked by default. The roles your identity provider put in the token are invisible to Spring Security, because they are nested one level deeper than the converter looks. And the JWK Set caching that everyone describes as “five minutes, rotates automatically” is doing something more specific than that, with two of its library’s protective defaults switched off — which decides whether a leaked signing key stops working in five minutes, or never. This article is about those three. It is a follow-up to Spring Security 7.1 JWT Authentication: The Complete Guide, which covered minting and verifying your own tokens; you do not need to have read it, but it is where the filter-chain and 401-vs-403 groundwork lives. Everything below was compiled and run. The companion repository, jwt-auth-demo, now holds two projects: the hand-written-filter application from the first article in jwt-authentication/, and the resource server here in oauth2-resource-server/. The second runs against a real Keycloak and against a stub authorization server whose signing keys can be rotated on command — because Keycloak will not rotate a key at a chosen second, will not tell you how many times its JWKS endpoint was fetched, and will not drop a key from the published set on request, and every measurement in Part 3 needs all three. Every transcript quoted here is a file in that repository.
PartRead it ifWhat it covers
1 — Beginneryou are wiring up a resource server for the first timeWhat one property actually does, the two-stage validation path, and where failures explain themselves
2 — Intermediateyour tokens validate but the authorization is wrongThe default validator stack, the audience hole, and why Keycloak’s roles are invisible
3 — Advancedyou are responsible for what happens when a key rotatesWhat the JWKS cache really does, how long a retired key lives, and a 1:1 request amplification
The parts build. The lazy decoder in Part 1 is why the cache in Part 3 behaves the way it does, and the validator stack in Part 2 is what you have to rebuild by hand if you take Part 3’s advice about rate limiting.
Versions. Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Security 7.1.1, Nimbus JOSE+JWT 10.9.1, Tomcat 11.0.24, Temurin JDK 25.0.4.1+1, Keycloak 26.7.2 (released 19 August 2026), Caffeine 3.2.4. Versions confirmed against Maven Central metadata and the projects’ own release pages, not against release-announcement blogs.

Behaviour described here was read from the Spring Security 7.1.1 and Nimbus 10.9.1 sources where the reference documentation and the code disagree, and then measured. Where they disagree, the code wins and I say so.

Part 1 — What that one property does

Nothing, until the first token arrives

issuer-uri makes Spring Boot create a SupplierJwtDecoder. The word that matters is supplier. At startup, nothing happens. On the first token decoded, three things happen in order:
  1. GET {issuer-uri}/.well-known/openid-configuration — the discovery document
  2. the issuer value inside it is compared against your configured issuer-uri, and a mismatch fails the whole decoder rather than one token
  3. GET {jwks_uri} — the JWK Set, both to learn which algorithms the issuer signs with and to fetch the keys
The laziness is deliberate and it is a good trade: your resource server starts even when the authorization server is down. It fails on the first request instead of failing to boot. The cost is that misconfiguration in this chain surfaces as a failed request rather than a failed startup, which is the wrong place for a deployment pipeline to find it. Setting jwk-set-uri alongside issuer-uri skips steps 1 and 2 — you keep issuer validation, you lose discovery, and you find out at boot whether the URL is reachable. Remember the laziness. In Part 3 it explains why the JWK Set caching is per-decoder, and why the first request after a deployment is slower than the rest.

Two stages, two libraries, two kinds of failure

A token that arrives at a resource server goes through two independent checks run by two different libraries, and then a conversion. Knowing which one refused tells you where to look.
Where a token dies, and with which status BearerToken AuthenticationFilter NimbusJwtDecoder.decode(token) 1. Nimbus signature, alg, kid 2. Spring Security iss exp nbf aud typ “Signed JWT rejected: no matching key(s) found” “The aud claim is not valid” “The iss claim is not valid” 401   invalid_token JwtAuthentication Converter claims → authorities 403   insufficient_scope Stage 1 and 2 failures are both 401 with error="invalid_token"; only the description tells them apart, and it is in the WWW-Authenticate header, never the body. A 403 means the token was fine and the converter did not produce the authority the rule wanted. Nothing anywhere says which authority that was.

Failures explain themselves in a header you are probably not looking at

A resource server returns an empty body on 401. The reason goes in WWW-Authenticate, per RFC 6750:
$ GET /api/me
HTTP 401
WWW-Authenticate: Bearer error="invalid_token",
  error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid",
  error_uri="https://tools.ietf.org/html/rfc6750#section-3.1",
  resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"
If you debug with a tool that hides response headers, every claim failure looks identical. Wrong issuer, wrong audience, expired, wrong typ — same status, same empty body. Curl with -i, or add the header to whatever you use, before you spend an afternoon on it.

An endpoint you did not configure

That last parameter in the challenge, resource_metadata, points somewhere new. Spring Security 7 publishes RFC 9728 protected resource metadata automatically, and it answers without a token — on a chain whose rule is anyRequest().authenticated():
$ GET /.well-known/oauth-protected-resource
HTTP 200
{"resource":"http://localhost:8081","bearer_methods_supported":["header"],
 "tls_client_certificate_bound_access_tokens":true}
It is standards-compliant and mostly harmless. It is also a new unauthenticated endpoint that appears when you upgrade, it confirms to an anonymous caller which authorization server you trust, and it will be in your next penetration test report. Know that it is there and that it is yours.

Part 2 — The token is valid. It still is not yours.

iss is compared with String.equals

Not normalised. Not parsed as a URI. Compared.
iss = "http://localhost:9000/other"
HTTP 401  error_description="... The iss claim is not valid"
The token in that capture was signed by the correct key, by the same issuer, seconds earlier. Only the string differed. A trailing slash is enough, and there is a test in the repository that pins exactly that. This is the leading cause of “it works with curl but not from the application” against Keycloak, because Keycloak derives iss from the request host unless you pin it. A token fetched through localhost:8080 and one fetched through keycloak:8080 from inside a Docker network carry different issuers, and exactly one matches your configuration. Pin it:
environment:
  KC_HOSTNAME: http://localhost:8080
  KC_HOSTNAME_STRICT: "false"

aud is not checked at all

This is the one to read twice. JwtValidators.createDefaultWithIssuer(issuer) — what Boot’s auto-configuration uses when you set only issuer-uri — builds a stack of JwtTypeValidator, JwtTimestampValidator, X509CertificateThumbprintValidator and JwtIssuerValidator. There is no audience validator in it. So a token minted by your issuer, for a different service in your realm, with a scope that service defines and yours does not, presented to your endpoint, is accepted:
iss = "http://localhost:9000"      <- correct
aud = "billing-api"                <- a different service entirely
HTTP 200
Whether that matters depends on how much you trust every other service that shares your identity provider, and on whether any of them ever leaks a token. In a realm with one client it is theoretical. In a realm with fifteen microservices it is the boundary between them. Three fixes, ordered by how little you have to write:
spring.security.oauth2.resourceserver.jwt.audiences: reports-api
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
    return new JwtAudienceValidator("reports-api");
}
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
        new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api")));
The middle one deserves attention. Boot’s JwtDecoderConfiguration collects every OAuth2TokenValidator<Jwt> bean in the context and appends it to the stack. Adding a validator does not require replacing the decoder — and replacing the decoder is how people accidentally lose the issuer validator they thought they still had.
setJwtValidator replaces the entire stack. The reference documentation shows decoder.setJwtValidator(new AudienceValidator()). Do that and your decoder now checks the audience and nothing else — not the issuer, not the expiry. It is a one-line downgrade that no happy-path test catches, because the happy path still returns 200. Always wrap with JwtValidators.createDefaultWithValidators(..), which keeps any of the three defaults you did not supply yourself.
Two details of JwtAudienceValidator that are easy to guess wrong, both pinned by tests in the repository: a token with aud: ["billing-api", "reports-api"] passes — it matches any entry, not all of them — and a token with no aud claim at all is refused, not ignored. Also worth knowing: JwtAudienceValidator is a first-class class as of Spring Security 6.5. The reference documentation still shows a hand-written AudienceValidator implementing OAuth2TokenValidator. You no longer need it.

The clock skew is 60 seconds and you will meet it

JwtTimestampValidator allows 60 seconds of skew by default, in both directions:
4. Expired 90 seconds ago   -> HTTP 401  "Jwt expired at 2026-08-23T10:26:18Z"
5. Expired 30 seconds ago   -> HTTP 200
Usually what you want across machines whose clocks disagree. Not what you want if you are writing a test that asserts a token stops working the instant it expires, and not what you want if your revocation story is “short-lived tokens” — your real worst case is the lifetime plus a minute. Changing it means building the validator yourself: new JwtTimestampValidator(Duration.ofSeconds(5)).

typ: at+jwt is refused by the default stack

RFC 9068 defines a media type for JWT access tokens and says one SHOULD carry typ: at+jwt in its JOSE header. The default stack contains JwtTypeValidator.jwt(), which accepts an absent typ or typ=JWT, and nothing else:
HTTP 401  error_description="... the given typ value needs to be one of [JWT]"
Keycloak is not affected: its JOSE header says typ: JWT and it puts Bearer in a claim of the same name, which nothing validates. An issuer that follows RFC 9068 more closely trips this. Either accept the type:
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
types.setAllowEmpty(true);
or validate the whole token as an RFC 9068 access token:
decoder.setJwtValidator(JwtValidators.createAtJwtValidator()
        .issuer(issuer).audience("reports-api").clientId("demo-client").build());
That second one is stricter than its name suggests. Its builder pre-populates required-claim validators for exp, sub, iat, jti and client_id. A Keycloak access token has no client_id claim — it puts the client in azp — so this builder refuses Keycloak tokens until you reconfigure it. Reach for it when you control the issuer, not as a general hardening switch.

Keycloak’s roles are invisible

Now the 403s. JwtGrantedAuthoritiesConverter, the default, reads the scope claim — or scp if scope is absent — splits it on whitespace, and prefixes each value with SCOPE_. That is the entire algorithm. Keycloak emits scope, so scopes work untouched. Roles do not, because Keycloak puts them here:
"realm_access":    { "roles": ["USER"] },
"resource_access": { "reports-api": { "roles": ["reports-reader"] } }
Neither is the scope claim. So the converter finds nothing, and every hasRole(..) rule returns 403 against a token that authenticated perfectly:
$ GET /api/me
HTTP 200
{ "name": "alice",
  "authorities": ["FACTOR_BEARER", "SCOPE_profile:read", "SCOPE_reports:read"] }

$ GET /api/reports      (needs ROLE_reports-reader, from resource_access)
HTTP 403  error="insufficient_scope"

$ GET /api/admin/stats  (needs ROLE_ADMIN, from realm_access)
HTTP 403  error="insufficient_scope"
Nothing in that 403 says which authority was missing, and nothing in the log says the roles were never read.

Route one: configuration only

Spring Boot 4 added authorities-claim-expressions, a list of SpEL expressions evaluated against the claim map. Nested claims need no Java at all:
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          principal-claim-name: preferred_username
          authority-prefix: "ROLE_"
          authorities-claim-expressions:
            - "[realm_access][roles]"
            - "[resource_access]['reports-api'][roles]"
Quote the hyphenated client id. Inside a SpEL indexer the contents are an expression, not a literal key, so [resource_access][reports-api][roles] parses as reports minus api:
[realm_access][roles]                     -> [USER]
[resource_access][reports-api][roles]     -> SpelEvaluationException: EL1008E:
                                             Property or field 'reports' cannot be found
[resource_access]['reports-api'][roles]   -> [reports-reader]
And the failure does not reach you:
catch (ExpressionException ee) {
    if (this.logger.isTraceEnabled()) {
        this.logger.trace(LogMessage.format("Failed to evaluate expression. error=%s", ee.getMessage()));
    }
    authorities = Collections.emptyList();
}
With TRACE turned on for that one class, the whole story is three lines:
Looking for authorities with expression. expression=[realm_access][roles]
Found authorities with expression. authorities=[USER]
Looking for authorities with expression. expression=[resource_access][reports-api][roles]
Failed to evaluate expression. error=EL1008E: Property or field 'reports' cannot be found
  on object of type 'java.util.Collections$UnmodifiableMap' - maybe not public or not valid?
A mistyped claim expression produces a 403 and total silence. No exception, no WARN, nothing at default log levels — the reason is logged at TRACE and then discarded. If a claim expression is not producing the authority you expect, turn on logging.level.org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE before you change anything else.
Two more limits of this route, both visible in the repository’s transcripts. authority-prefix is a single value applied to every expression, so a mixed mapping — SCOPE_ for scopes, ROLE_ for roles — cannot be expressed here. And naming any expression replaces the default converter, so the SCOPE_* authorities vanish; adding [scope] as an expression brings the values back with a ROLE_ prefix, which is not what you meant.

Route two: a bean

When the mapping is mixed, write it:
@Bean
JwtAuthenticationConverter keycloakJwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setPrincipalClaimName("preferred_username");
    converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api"));
    return converter;
}
Which produces, against a real Keycloak token:
"authorities": ["FACTOR_BEARER", "ROLE_USER", "ROLE_reports-reader",
                "SCOPE_email", "SCOPE_profile"]
Defining that bean silently disables every one of the properties above. Boot’s JwtConverterConfiguration is annotated @ConditionalOnMissingBean(JwtAuthenticationConverter.class), so the moment your bean exists, principal-claim-name, authority-prefix, authorities-claim-name, authorities-claim-delimiter and authorities-claim-expressions stop having any effect. No warning is logged. If you have both, the YAML is decoration — delete it, so the next person does not read it and believe it.
One more thing about flattening: mapping realm roles and client roles into one ROLE_ namespace reads well and matches what hasRole(..) expects, but if two clients in your realm each define a role called admin, both collapse onto ROLE_admin and a token for one passes a check meant for the other. Prefix by client id if that is a real risk in your realm.

Part 3 — What the JWKS cache really does

Everyone repeats the same sentence: Spring Security caches the JWK Set for five minutes and rotates keys automatically. It is half true. The half that is not decides how long a compromised signing key keeps working.

One method

NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource(), in Spring Security 7.1.1:
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();
}
Now Nimbus’s own defaults, from JWKSourceBuilder:
LayerNimbus defaultSpring Security 7.1.1
cachingtrue, TTL 5 min, refresh timeout 15 sonly when no Spring cache was supplied
refreshAheadtrue, 30 s ahead of expiryfalse
rateLimitedtrue, min 30 s between forced refreshesfalse
outageTolerantfalsefalse
retryingfalsefalse
Two of the library’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 stops being true the moment you supply one. The reference documentation recommends supplying a cache in order to share the JWK Set across instances, and does not mention that doing so removes the five-minute expiry.
The JWK source chain, as built — not as documented Default: no Spring cache supplied JWKSetBasedJWKSource RateLimitedJWKSetSource — rateLimited(false) RefreshAheadCaching — refreshAheadCache(false) CachingJWKSetSource   ttl 5 min SpringJWKSource → HTTP With a Spring cache supplied JWKSetBasedJWKSource RateLimitedJWKSetSource — off RefreshAheadCaching — off CachingJWKSetSource — ALSO off SpringJWKSource → your cache → HTTP Dashed layers were switched off by Spring Security. On the right the five-minute expiry is gone too, and the only TTL in the system is whatever your cache provider has. A ConcurrentMapCache has none. Printed from the live object graph by /api/public/decoder, not inferred: docs/output/rs-decoder-chain.txt
You do not have to take the source’s word for any of that. The companion repository adds a diagnostic endpoint that walks the live object graph inside the running decoder and prints the layers it finds:
DEFAULT (no Spring cache)                    WITH A CAFFEINE CACHE SUPPLIED
"jwkSourceChain": [                          "jwkSourceChain": [
  { "class": "JWKSetBasedJWKSource" },         { "class": "JWKSetBasedJWKSource" },
  { "class": "CachingJWKSetSource",            { "class": "...SpringJWKSource",
    "timeToLiveMs": 300000,                        "springCache": "CaffeineCache" }
    "cacheRefreshTimeoutMs": 15000 },        ]
  { "class": "...SpringJWKSource",
    "springCache": "NoOpCache" }
]
On the left, timeToLiveMs: 300000 read from the live object — the five minutes everyone quotes, and it is real. On the right the whole layer is gone, and the Caffeine cache’s own TTL is the only expiry in the system. There is no RateLimitedJWKSetSource in either. Printing the real thing beats asserting the remembered thing. This endpoint is what caught the difference between what I expected and what was there. It also reveals your JWK Set URI and cache timings to anyone who can reach it, so it is a diagnostic and not a feature — delete it before you ship.

What actually triggers a re-fetch

JWKSetBasedJWKSource.get is the entire 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 the JWK Set is re-fetched when, and only when, a token arrives whose kid is not in the cached set, or the cache expires on its own. With refreshAhead(false), that expiry refresh is always synchronous, on a request thread. One unlucky request every cache lifetime pays for a round trip to your identity provider.

Rotation is three events, not one

Conflating them is where rotation incidents come from. The stub issuer fires them separately on command, so the resource server can be watched in between.
Three events, and what the resource server knows after each PUBLISH ACTIVATE RETIRE key 2 joins the JWK Set issuer signs with key 2 from now on key 1 removed from the JWK Set resource server’s cached JWK Set [key 1] [key 1] → [key 1, key 2] [key 1, key 2] — stale unchanged: nothing forced a look an unknown kid forced a refresh unchanged: nothing forced a look tokens signed with key 1:   200  ·  200  ·  200  ·  200 … until the cache expires The only thing that noticed the rotation was a request that failed to find its key. There is no scheduled refresh and no notification — recovery is driven by the failure. Refresh-ahead, which would have polled, is off. Measured in docs/output/rs-rotation.txt.
From the captured run:
0. Warm the cache
   jwks fetches so far: 1        <- discovery, from a cold start

1. PUBLISH a second key. 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: nothing forced a refresh

2. ACTIVATE the new key. The issuer starts signing with it.
   New token: 200
   jwks fetches so far: 2        <- the unknown kid forced one

4. RETIRE the old key 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
The recovery is driven by the failure. There is no scheduled refresh, no background poll, no notification. The first token signed with a new key is the mechanism by which a resource server learns the key exists. That works, and it is why routine rotation is usually invisible. It is also why the length of the publish window — between a key appearing in the JWK Set and the issuer starting to sign with it — is the only thing making rotation safe.

How long does a leaked key keep working?

This is the question the caching behaviour actually answers, and it is the one worth being able to answer about your own system. The scenario: a signing key is compromised. The issuer publishes a replacement, activates it, and removes the compromised key from the JWK Set immediately. Tokens signed with it are already out there with an hour to run. When do your resource servers stop honouring them? The measurement has to be narrow to mean anything. 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 — and the attacker holding it has no reason to send anything else. Nothing can dislodge the cache except its own expiry. Two runs, identical but for one line of configuration.
DEFAULT  (no Spring cache)            SPRING CACHE WITH NO TTL
elapsed  leaked  jwksFetches          elapsed  leaked  jwksFetches
t+0s     200     1                    t+0s     200     0
t+120s   200     1                    t+120s   200     0
t+240s   200     1                    t+240s   200     0
t+270s   200     1                    t+270s   200     0
t+300s   401     3                    t+300s   200     0
t+330s   401     4                    t+330s   200     0
t+390s   401     6                    t+390s   200     0
t+450s   401     8                    t+450s   200     0
On the left, Nimbus’s CachingJWKSetSource expires the set at DEFAULT_CACHE_TIME_TO_LIVE, which is 5 * 60 * 1000L. The refresh happens, the retired key is gone, and the leaked token stops working — at t+300s, to the second. The fetch counter on the left then climbs by one per probe, because the leaked token’s kid is now unknown and every attempt forces another refresh. That is the amplification in the next section, arriving unbidden. On the right, nothing expires. The fetch counter has not moved since before the retirement: in seven and a half minutes the resource server made no requests to the issuer, and had no reason to. The compromised token keeps working for as long as it is valid.
The cache on the right is not exotic. It is a ConcurrentMapCache — which is also what ConcurrentMapCacheManager hands out, which is what Spring Boot gives you when spring-boot-starter-cache is on the classpath and no cache provider is configured. Someone adds caching for an unrelated reason, someone else follows the reference documentation’s advice to share the JWK Set across instances, and the revocation window for your signing keys quietly becomes forever. Neither change looks like it touched security.
If you supply a cache, give it a TTL, and make the TTL a decision:
Cache cache = new CaffeineCache("jwks",
        Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build());
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).cache(cache).build();
And give the JWK Set a cache of its own. SpringJWKSource.getJWKSet calls this.cache.invalidate() when a refresh is required — not evict(key). That clears the entire cache, not just the JWK Set entry. Point it at a cache you share with anything else and unauthenticated traffic will empty it for you.

What an unknown kid costs your identity provider

Back to rateLimited(false). Nimbus rate-limits forced refreshes by default, with DEFAULT_RATE_LIMIT_MIN_INTERVAL = 30_000L. Spring Security turns it off. With nothing between an unrecognised kid and the network, the refresh that makes rotation work becomes something anyone can drive. The demo sends 25 requests to the resource server and asks the issuer — which counts its own fetches, so this is measured rather than inferred — how many times its JWK Set was fetched:
Baseline: 25 requests with a VALID token, whose kid is in the cached JWK Set.
  requests to the resource server : 25
  fetches of /jwks.json           : 0

Now 25 requests carrying a token whose kid has never existed.
  requests to the resource server : 25
  fetches of /jwks.json           : 25
One to one. Every rejected request became an outbound HTTP request to the authorization server, from a resource server that has not authenticated anybody. It does not even need an endpoint that requires authentication. Repeating it against /api/public/ping, whose rule is permitAll():
  requests to /api/public/ping    : 25
  fetches of /jwks.json           : 25
BearerTokenAuthenticationFilter runs before any authorization rule, so a public endpoint still evaluates a bearer token if one is present. Sending a broken token to a permitAll() endpoint gets you a 401 from that public endpoint — and a JWKS fetch on the way. The token costs nothing to make. It does not have to verify; it does not even have to be signed by anything real. It only has to carry a kid the resource server has not seen, which is a random string. And the response gives nothing away:
HTTP 401  error_description="An error occurred while attempting to decode the Jwt:
  Signed JWT rejected: Another algorithm expected, or no matching key(s) found"
The amplification is per instance, and it is reachable before any authorization rule runs. The JWKS fetch happens during token decoding, so every endpoint that accepts a bearer token is an entry point — including the ones marked permitAll() — and a fleet of resource servers multiplies the ratio. The traffic arrives at your identity provider from your own services, which are on its allow-lists, and it lands on the JWKS endpoint — which sits in front of the token endpoint every one of your services depends on, and which nobody capacity-plans for.

I have not found this discussed anywhere as a consideration and would be glad to be shown it is. The behaviour is not in doubt: Nimbus defends against it, Spring Security opts out in one line of a package-private method, and there is no property to change it.

Restoring the layers

The builder exposes no toggle for any of this, but NimbusJwtDecoder.withJwkSource(..) accepts a fully built source, so you can assemble it yourself:
JWKSource<SecurityContext> source = JWKSourceBuilder
        .<SecurityContext>create(new URI(jwkSetUri).toURL())
        .cache(Duration.ofMinutes(5).toMillis(), Duration.ofSeconds(15).toMillis())
        .refreshAheadCache(true)
        .rateLimited(Duration.ofSeconds(30).toMillis())
        .outageTolerant(Duration.ofMinutes(30).toMillis())
        .retrying(true)
        .build();
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSource(source).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
        new JwtIssuerValidator(issuer), new JwtAudienceValidator(audience)));
That is a profile in the companion repository, so it compiles and runs rather than being a sketch. The same diagnostic endpoint confirms what it built:
"jwkSourceChain": [
  { "class": "JWKSetBasedJWKSource" },
  { "class": "RefreshAheadCachingJWKSetSource", "timeToLiveMs": 300000, "cacheRefreshTimeoutMs": 15000 },
  { "class": "RateLimitedJWKSetSource", "minTimeIntervalMs": 30000 },
  { "class": "OutageTolerantJWKSetSource" },
  { "class": "RetryingJWKSetSource" },
  { "class": "URLBasedJWKSetSource" }
]
Six layers against the default’s three. Three things it costs you, and they are not small:
  • Issuer discovery is gone. You configure the JWK Set URI explicitly.
  • The validator stack is no longer supplied. This is where Part 2’s point about setJwtValidator replacing everything comes due — you now own the whole stack, and forgetting JwtIssuerValidator here is a real downgrade.
  • Spring’s RestOperations is out of the loop. JWKSourceBuilder.create(URL) fetches with Nimbus’s own DefaultResourceRetriever, so client customisation, proxy configuration and observability you wired into the Spring HTTP client no longer apply to JWKS fetches.
And rate limiting has its own trade: during a genuine rotation, tokens signed with the new key are refused for up to the interval after the first miss. Thirty seconds of 401s during a planned rotation, against an unbounded outbound request rate anyone can trigger. For most services that is the right way round — but it is a decision, not a default. Cheaper measures that need no code:
  • Alert on your JWKS endpoint’s request rate. A rate that tracks request volume rather than instance count means this is already happening. It is the cheapest detection available and almost nobody is watching that metric.
  • Rate-limit 401 responses per client at the edge. Worth having anyway.
  • Set an explicit TTL on any cache you supply, and do not share it.

The long tail

Things that cost real time while building the companion repository, each one line here and a chapter there.
  • A clientScopes key in a Keycloak realm import replaces the built-ins rather than adding to them. The realm then has no roles or profile scope, tokens arrive with no realm_access and no preferred_username, and it looks exactly like a broken converter. docs/17
  • A Keycloak user with no firstName and lastName fails the password grant with "Account is not fully set up", which names nothing useful. docs/17
  • Keycloak adds no aud for your resource server without an audience protocol mapper, so turning on audience validation refuses every token until you add one. docs/17
  • A JWK Set contains keys you must not verify with. Keycloak publishes an RSA-OAEP encryption key alongside the RS256 signing key. Nimbus filters on use and alg before matching kid; anything you write yourself must too. docs/17
  • The reference documentation’s validateTypes(false) does not exist. The method on JwkSetUriJwtDecoderBuilder in 7.1.1 is validateType, singular. docs/13
  • There are two independent typ checks — Nimbus’s JOSEObjectTypeVerifier, off by default, and Spring’s JwtTypeValidator, on. Only the second one is refusing your at+jwt tokens. docs/13
  • Keycloak’s typ is a claim as well as a header, with different values: JWT in the JOSE header, Bearer in the claim set. Validators read the header. docs/17
  • Spring Boot 4’s Maven plugin renamed the run-goal property to -Dspring-boot.run.main-class. The Boot 3 spelling -Dspring-boot.run.mainClass is now ignored silently, and a module with two main classes starts the wrong one.
  • authorities-claim-expressions, authorities-claim-name and authorities-claim-delimiter are mutually exclusive and throw at startup if combined — the one failure in this whole article that is loud. docs/14
  • Outage tolerance and retry are both off. If the issuer is unreachable when your cache expires, every request fails until it is back, and a single failed HTTP call fails a request. Nimbus has OutageTolerantJWKSetSource; Spring Security does not wire it in. docs/15

Should you build this at all

If your service mints its own tokens for its own users, none of this applies to you and you should not adopt it. A resource server exists to trust an issuer you did not write. If there is no such issuer, the hand-written filter from the previous article is less code, fewer moving parts, and none of the failure modes above — no JWKS, no cache, no rotation, no discovery.

The resource server earns its complexity when tokens come from somewhere you do not control: several services trusting one issuer, an identity provider somebody else operates, key rotation that has to happen without redeploying anything. If that is not your situation, most of this article is a list of ways to get something wrong that you could simply not have.

The short version

ThingDefaultWhat to do
aud validationnot checkedone property, or an OAuth2TokenValidator bean
iss comparisonexact string equalitypin KC_HOSTNAME; watch trailing slashes
clock skew60 s each wayknow it; change it deliberately
typ: at+jwtrefuseda permissive JwtTypeValidator, if your issuer emits it
setJwtValidatorreplaces everythingwrap with createDefaultWithValidators
nested rolesinvisiblequoted SpEL expressions, or a converter bean — not both
JWKS cache TTL5 min, unless you supply a cacheif you supply one, give it a TTL and do not share it
refresh-aheadoffaccept the synchronous refresh, or build the source yourself
rate limitingoffalert on JWKS request rate at minimum
outage toleranceoffdecide whether an issuer outage should take you down

Further reading

The companion repositoryankurm.com/git.app/asmhatre/jwt-auth-demo. Two runnable projects, eighteen documentation chapters, and every transcript quoted above. On this site Primary sources

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.