1
0
Files
jwt-auth-demo/docs/13-validator-stack.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

128 lines
5.3 KiB
Markdown

# 13 — The validator stack
[← issuer and audience](12-issuer-and-audience.md) · [next: the authentication converter →](14-authentication-converter.md)
Signature verification and claim validation are two separate stages, run by two different
libraries. Nimbus verifies the signature; Spring Security validates the claims. Knowing
which stage refused a token tells you where to look.
```
BearerTokenAuthenticationFilter
└─ JwtAuthenticationProvider
└─ NimbusJwtDecoder.decode(token)
├─ 1. Nimbus DefaultJWTProcessor signature, alg, kid → key
└─ 2. Spring OAuth2TokenValidator iss, exp, nbf, aud, typ, ...
└─ JwtAuthenticationConverter claims → authorities (chapter 14)
```
Stage 1 failures read like `Signed JWT rejected: Another algorithm expected, or no matching
key(s) found`. Stage 2 failures name the claim: `The iss claim is not valid`. Both arrive as
`invalid_token` in the `WWW-Authenticate` header, so the description is the only thing that
distinguishes them.
## What is actually in the default stack
From `JwtValidators` in Spring Security 7.1.1:
| factory | contents |
|---|---|
| `createDefault()` | `JwtTypeValidator.jwt()`, `JwtTimestampValidator`, `X509CertificateThumbprintValidator` |
| `createDefaultWithIssuer(iss)` | the above plus `JwtIssuerValidator(iss)` |
| `createDefaultWithValidators(..)` | your validators, plus any of the three above you did not supply |
| `createAtJwtValidator()` | a builder for RFC 9068 access tokens |
Three things follow from that table.
**There is no audience validator.** Covered in [chapter 12](12-issuer-and-audience.md); it
is the most consequential omission in the list.
**`createDefaultWithValidators` adds, it does not replace.** Pass it a
`JwtTimestampValidator` with your own clock skew and it uses yours; pass it nothing of the
kind and it inserts the default. That is why supplying a custom timestamp validator works
without also having to re-supply the type and thumbprint validators.
**`createAtJwtValidator()` is much stricter than the name suggests.** Its builder
pre-populates required-claim validators for `exp`, `sub`, `iat`, `jti` and `client_id`, plus
a type validator restricted to `at+jwt` and `application/at+jwt`. A token missing any one of
those is refused. Reach for it when you control the issuer and it genuinely emits RFC 9068
access tokens — not as a general-purpose hardening switch.
## Two places a `typ` check can live
There are *two* independent type checks, and only one of them is on by default.
```java
NimbusJwtDecoder.withIssuerLocation(issuer)
.validateType(true) // Nimbus-level. Default: FALSE.
.build();
```
`validateType(boolean)` swaps Nimbus's `JOSEObjectTypeVerifier` between a no-op and one that
demands `typ=JWT`. It defaults to the no-op. Meanwhile the Spring-level
`JwtTypeValidator.jwt()` inside the default validator stack *is* present and *does* demand
`typ=JWT` or nothing. So the type is checked once, by Spring, on the way out.
Note the spelling. The reference documentation shows `validateTypes(false)`, plural. The
method on `JwkSetUriJwtDecoderBuilder` in 7.1.1 is **`validateType`**, singular. Reading the
docs and typing what they say does not compile.
## Adding a validator without losing the ones you have
The safe route, because it does not touch the decoder at all:
```java
@Bean
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtAudienceValidator("reports-api");
}
```
Boot's `JwtDecoderConfiguration` gathers every such bean and appends it. The dangerous route
is the one the reference documentation demonstrates:
```java
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
decoder.setJwtValidator(new JwtAudienceValidator("reports-api")); // ← WRONG
return decoder;
}
```
`setJwtValidator` **replaces**. That decoder now checks the audience and nothing else — not
the issuer, not the expiry. It is a one-line downgrade that no test of the happy path will
catch, because the happy path still returns 200. Wrap with `createDefaultWithValidators` and
you keep everything:
```java
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator(issuer), new JwtAudienceValidator("reports-api")));
```
## Pin the defaults in a test
The validator stack is exactly the kind of thing that changes between minor versions and
fails closed when it does — or worse, fails open. Nine assertions in
[`JwtValidationContractTests`](../oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java)
assert the *defaults* rather than this application's configuration, so that a Spring Security
upgrade that moves them turns a test red instead of turning a production check off:
```
defaultStackAcceptsAWellFormedToken
defaultStackDoesNotCheckAudience
addingJwtAudienceValidatorRefusesTheSameToken
defaultStackRefusesRfc9068AccessTokens
aPermissiveTypeValidatorAcceptsThem
issuerComparisonIsExactStringEquality
defaultClockSkewIsSixtySeconds
audienceValidatorMatchesAnyEntryNotAllOfThem
aMissingAudienceClaimIsRefusedNotIgnored
```
Run: `cd oauth2-resource-server && mvn test`. Result committed in
[`rs-test-run.txt`](output/rs-test-run.txt).
---
[← issuer and audience](12-issuer-and-audience.md) · [next: the authentication converter →](14-authentication-converter.md)