1
0
Files
jwt-auth-demo/docs/16-jwks-amplification.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
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
2026-08-23 11:00:56 +00:00

6.1 KiB

16 — What an unknown kid costs your identity provider

← JWKS caching and rotation · next: Keycloak setup →

Nimbus rate-limits forced JWK Set refreshes by default: rateLimited = true, with DEFAULT_RATE_LIMIT_MIN_INTERVAL = 30_000L. Spring Security turns it off.

JWKSourceBuilder.create(new SpringJWKSource<>(...))
    .refreshAheadCache(false)
    .rateLimited(false)          // ← this line
    .cache(this.cache instanceof NoOpCache)
    .build();

With nothing between an unrecognised kid and the network, the refresh that chapter 15 describes as the recovery mechanism becomes something an attacker can drive.

The measurement

amplification-demo.sh sends 25 requests to the resource server and counts how many times the issuer's /jwks.json is fetched. The issuer counts its own fetches, so this is not inferred from logs.

From rs-jwks-amplification.txt:

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.

The token itself is trivially cheap 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:

$ GET /api/me with an unknown kid
HTTP 401
WWW-Authenticate: Bearer error="invalid_token",
  error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected:
  Another algorithm expected, or no matching key(s) found"

Nothing in that response suggests anything unusual happened. The caller sees a 401. Your identity provider sees the traffic.

It does not need an endpoint that requires authentication. The same 25 requests 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 when one is present. A broken token sent to a permitAll() endpoint returns 401 from that public endpoint, and fetches the JWK Set on the way. Any endpoint reachable without credentials is an entry point.

Why this is worse than it first looks

  • The amplification is per resource server instance, and every instance has its own cache, so a fleet multiplies it.
  • It is reachable through any endpoint at all, permitAll() ones included, because the JWKS fetch happens during token decoding, before any authorization rule runs.
  • Keycloak's JWKS endpoint is not usually the thing you capacity-plan for, and it sits in front of the token endpoint that every one of your services depends on.
  • The requests come from your own resource servers, which are on your identity provider's allow-lists.

I have not found this documented anywhere as a consideration, and I would be glad to be shown it is. What is certain is the behaviour: Nimbus defends against it, Spring Security opts out, and the opt-out is one line in a package-private method with no property to change it.

What you can do

Restore rate limiting. Build the JWKSource yourself and hand it over. The builder does not expose the toggle, but withJwkSource accepts a fully-built source:

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 the hardened profile in JwtDecoderConfig, so it compiles and runs rather than being a sketch. new URL(String) is deprecated for removal on modern JDKs; new URI(..).toURL() is the replacement.

You lose three things: issuer discovery, so the JWK Set URI has to be configured explicitly; the validator stack that withIssuerLocation supplied, which now has to be set in full; and Spring's RestOperations, because JWKSourceBuilder.create(URL) fetches with Nimbus's own DefaultResourceRetriever — any client customisation, proxy configuration or observability wired into the Spring HTTP client no longer applies to JWKS fetches. In exchange you get every protective layer Nimbus offers, which is more than the default gives you.

The trade is real: rate limiting means that during a genuine rotation, tokens signed with the new key are refused for up to the rate-limit interval after the first miss. Thirty seconds of 401s during a planned rotation, against an unbounded outbound request rate that anyone can trigger. For most services that is the right way round, but it is a decision, not a default.

Rate-limit at the edge. A limit on 401 responses per client is worth having anyway and costs nothing here.

Alert on the JWKS endpoint. A fetch rate that tracks your request rate rather than your instance count means this is happening. It is the cheapest detection available and most people are not looking at that metric at all.

Do not share one cache. Chapter 15 covers cache.invalidate() clearing everything; combined with this, a shared cache is repeatedly emptied by unauthenticated traffic.


← JWKS caching and rotation · next: Keycloak setup →