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
136 lines
6.1 KiB
Markdown
136 lines
6.1 KiB
Markdown
# 16 — What an unknown `kid` costs your identity provider
|
|
|
|
[← JWKS caching and rotation](15-jwks-caching-and-rotation.md) · [next: Keycloak setup →](17-keycloak-setup.md)
|
|
|
|
Nimbus rate-limits forced JWK Set refreshes by default: `rateLimited = true`, with
|
|
`DEFAULT_RATE_LIMIT_MIN_INTERVAL = 30_000L`. Spring Security turns it off.
|
|
|
|
```java
|
|
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](15-jwks-caching-and-rotation.md) describes as the recovery mechanism becomes
|
|
something an attacker can drive.
|
|
|
|
## The measurement
|
|
|
|
[`amplification-demo.sh`](../oauth2-resource-server/scripts/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`](output/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:
|
|
|
|
```java
|
|
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`](../oauth2-resource-server/src/main/java/com/ankurm/rsdemo/config/JwtDecoderConfig.java),
|
|
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](15-jwks-caching-and-rotation.md) covers
|
|
`cache.invalidate()` clearing everything; combined with this, a shared cache is repeatedly
|
|
emptied by unauthenticated traffic.
|
|
|
|
---
|
|
|
|
[← JWKS caching and rotation](15-jwks-caching-and-rotation.md) · [next: Keycloak setup →](17-keycloak-setup.md)
|