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
5.3 KiB
13 — The validator stack
← issuer and audience · next: the authentication converter →
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; 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.
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:
@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:
@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:
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
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.
← issuer and audience · next: the authentication converter →