# 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 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)