skip to content

Beyond signature verification, what claims should a JWT resource server validate, and how do you add a custom validator such as an audience check?

level: seniorimportance: should knowfreq 52%

answer

  1. signature != claims
  2. defaults: exp/nbf + issuer (with issuer-uri)
  3. aud check stops cross-service token reuse
  4. DelegatingOAuth2TokenValidator over defaults
  5. setJwtValidator replaces chain -- delegate on

basics

~10 s

Validate exp/nbf (timestamps) and iss (issuer) — the defaults cover these when you use issuer-uri. Add an audience (aud) check with a custom OAuth2TokenValidator<Jwt> combined via DelegatingOAuth2TokenValidator and set it on the NimbusJwtDecoder.

solid answer

~40 s

Signature only proves authenticity — you still must validate claims. The default validator chain checks `exp`/`nbf` timestamps (`JwtTimestampValidator`) and, with issuer-uri, the `iss` claim (`JwtIssuerValidator`). A critical extra check is **audience** (`aud`): a token minted for another resource server but signed by the same authorization server would otherwise be accepted — you should assert `aud` contains your resource server's identifier. Implement an `OAuth2TokenValidator<Jwt>`, combine it with the defaults using `DelegatingOAuth2TokenValidator`, and call `decoder.setJwtValidator(...)`. `JwtValidators.createDefaultWithIssuer(issuer)` gives you the standard chain to delegate onto. Each validator returns an `OAuth2TokenValidatorResult`; any failure makes `decode()` throw and the request gets 401. You can also tune allowable clock skew on `JwtTimestampValidator` for fleet clock drift.

code

java · 22 lines
java
@Bean
JwtDecoder jwtDecoder(@Value("${issuer}") String issuer) {
    NimbusJwtDecoder decoder =
        (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);

    OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
        JwtValidators.createDefaultWithIssuer(issuer), // exp/nbf + iss
        new AudienceValidator("katajob-api"));         // custom aud check
    decoder.setJwtValidator(validators);
    return decoder;
}

static final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
    private final String audience;
    AudienceValidator(String audience) { this.audience = audience; }
    @Override public OAuth2TokenValidatorResult validate(Jwt jwt) {
        return jwt.getAudience().contains(audience)
            ? OAuth2TokenValidatorResult.success()
            : OAuth2TokenValidatorResult.failure(
                new OAuth2Error("invalid_token", "Missing required audience", null));
    }
}

go deeper

for a junior

Know that exp and iss are checked and a token can be authentic but expired.

for a middle

Explain the default validator chain and that jwk-set-uri omits the issuer check.

for a senior

Implement an audience validator, delegate onto defaults, and tune clock skew.

for a principal

Define a validation policy (aud, issuer, freshness, skew) across all services and weigh JWT expiry vs introspection for revocation SLAs.

## Signature ≠ authorization A valid signature means "this token was really issued by the authorization server and wasn't tampered with." It says nothing about *whether this token is meant for you, still fresh, or from the expected issuer*. Those are **claim validations**. ## The default validator chain Spring models validation as `OAuth2TokenValidator<Jwt>` returning an `OAuth2TokenValidatorResult` (success or a set of `OAuth2Error`s). The decoder holds one validator, usually a **`DelegatingOAuth2TokenValidator`** that runs several: - **`JwtTimestampValidator`** — enforces `exp` (expiry, token not expired) and `nbf` (not-before, token already valid), with a default 60-second clock skew leeway. - **`JwtIssuerValidator`** — asserts `iss` equals the configured issuer. Added automatically only with `issuer-uri` (or `JwtValidators.createDefaultWithIssuer`). `JwtValidators.createDefault()` gives timestamp-only; `createDefaultWithIssuer(iss)` adds the issuer validator. ## Why audience matters In a multi-service estate, one authorization server signs tokens for many resource servers. A token issued to reach *service A* is signed by the same key and passes signature + issuer + timestamp checks at *service B*. If service B doesn't validate **`aud`** (audience), it wrongly accepts a token never intended for it — a real privilege/confused-deputy risk. So every resource server should validate that `aud` (or a provider-specific claim like Keycloak's `azp`/`resource_access`) names *it*. ## Writing and wiring a custom validator ```java class AudienceValidator implements OAuth2TokenValidator<Jwt> { private final String audience; AudienceValidator(String audience) { this.audience = audience; } public OAuth2TokenValidatorResult validate(Jwt jwt) { if (jwt.getAudience() != null && jwt.getAudience().contains(audience)) { return OAuth2TokenValidatorResult.success(); } return OAuth2TokenValidatorResult.failure( new OAuth2Error("invalid_token", "Required audience missing", null)); } } ``` Combine with defaults and install on the decoder: ```java NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer); OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuer), new AudienceValidator("katajob-api")); decoder.setJwtValidator(withAudience); ``` Return this `NimbusJwtDecoder` as a `@Bean JwtDecoder` and `oauth2ResourceServer().jwt()` uses it. ## Clock skew Distributed clocks drift. `JwtTimestampValidator` accepts a `Duration` leeway (default 60s). Tighten for stricter freshness, loosen if you see spurious "token expired"/"used before nbf" 401s from clock drift — but keep it small. ## Failure behavior Any validator failure → `decode()` throws `JwtValidationException` (a `BadJwtException`) → `JwtAuthenticationProvider` raises an `AuthenticationException` → `BearerTokenAuthenticationEntryPoint` returns **401** with a `WWW-Authenticate: Bearer error="invalid_token"` header describing the reason. ## Gotchas - With `jwk-set-uri` there is NO issuer validator by default — add `JwtIssuerValidator` yourself. - `aud` can be a string or an array; use `Jwt.getAudience()` which returns a `List<String>`. - Don't confuse authentication-time claim validation with authorization (scopes/roles) — audience is validated in the decoder, scopes in the authorities converter. - Overriding `setJwtValidator` **replaces** the whole chain; always delegate onto the defaults or you silently drop timestamp/issuer checks. - Expiry checks are only as good as short token lifetimes; long-lived tokens plus no introspection means slow revocation.

  • Why is validating aud important even when the signature and issuer are already valid?
    One authorization server signs tokens for many resource servers. A token minted for another service passes signature + issuer + timestamp at yours. Checking aud ensures the token was actually intended for your resource server, preventing confused-deputy/cross-service token reuse.
  • What happens if you call decoder.setJwtValidator with only your audience validator?
    You replace the entire default chain, silently dropping the timestamp and issuer checks — so expired or wrong-issuer tokens would pass. Always wrap the defaults (JwtValidators.createDefaultWithIssuer) and your validator in a DelegatingOAuth2TokenValidator.

saying these in an interview costs you the question

  • Believing a valid signature is sufficient and claims need no checking
  • Not validating audience in a multi-resource-server environment
  • Replacing the validator chain and dropping the default timestamp/issuer validators
  • Thinking exp checking gives instant revocation for long-lived tokens

context