Skip to main content
spring internals

JwtDecoder and Token Validation

8 min read Chapter 41 of 78

The Annotation

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .build();
}

One line. One URI. Behind it, a cryptographic pipeline that retrieves public keys over HTTP, matches them to incoming tokens by key ID, verifies RSA or EC signatures, checks timestamps against the system clock, and runs custom validation rules. Every step can fail, and every failure produces a different exception. This section traces the full decode path.

The Mechanism

NimbusJwtDecoder delegates to the Nimbus JOSE + JWT library. The .decode(String token) method runs three phases in sequence.

Phase 1: Parse

The raw JWT string is split on the two . delimiters into three Base64URL-encoded segments: header, payload, signature. Nimbus parses the header to extract the algorithm (alg) and key ID (kid). The payload is parsed into a claims set but not yet trusted.

// Nimbus internals (simplified)
SignedJWT signedJwt = SignedJWT.parse(token);
JWSHeader header = signedJwt.getHeader();
// header.getAlgorithm() -> RS256
// header.getKeyID()     -> "key-2024-06"

If the token is not valid JWT syntax (wrong number of segments, invalid Base64URL), parsing fails with a ParseException. Spring wraps this in a BadJwtException with the message “An error occurred while attempting to decode the Jwt.”

Phase 2: Signature Verification

The decoder needs the public key that corresponds to the kid in the JWT header. NimbusJwtDecoder configured with .withJwkSetUri() uses a JWKSource that fetches keys from the remote JWK Set endpoint.

The fetch cycle:

  1. On first call, the RemoteJWKSet sends an HTTP GET to the JWK Set URI.
  2. The response is a JSON document containing an array of JWK (JSON Web Key) objects, each with a kid, algorithm, and key material.
  3. The keys are parsed and cached in a JWKSetCache.
  4. The RemoteJWKSet matches the JWT’s kid against the cached keys.
  5. If no match is found, it fetches the JWK Set again (one retry) in case the authorization server rotated keys.
  6. The matching key is used to verify the JWT signature.
// What NimbusJwtDecoder does internally
JWSVerifierFactory verifierFactory = new DefaultJWSVerifierFactory();
JWSVerifier verifier = verifierFactory.createJWSVerifier(header, publicKey);
boolean valid = signedJwt.verify(verifier);

For RSA keys (RS256, RS384, RS512), the verifier uses java.security.Signature with the appropriate algorithm. For EC keys (ES256, ES384, ES512), it uses ECDSA verification. The key type in the JWK must match the algorithm in the JWT header. A mismatch throws JOSEException.

If verification fails, the signature does not match the payload. The token was either tampered with or signed by a different key. Spring throws BadJwtException with “Signed JWT rejected: Invalid signature.”

Phase 3: Claim Validation

After signature verification, the payload is trusted. Nimbus extracts the claims, and Spring runs them through a chain of OAuth2TokenValidator<Jwt> instances.

The default validators installed by JwtValidators.createDefault():

  • JwtTimestampValidator: Checks exp (expiration) is in the future and nbf (not before) is in the past. Uses a configurable clock skew (default: 60 seconds) to account for clock drift between servers.
  • JwtIssuerValidator: If configured, checks that the iss claim matches the expected issuer URI.
// JwtTimestampValidator logic (simplified)
Instant expiry = jwt.getExpiresAt();
Instant now = Instant.now(this.clock);
if (expiry != null && now.minus(this.clockSkew).isAfter(expiry)) {
    throw new JwtValidationException("Jwt expired at " + expiry);
}

Instant notBefore = jwt.getNotBefore();
if (notBefore != null && now.plus(this.clockSkew).isBefore(notBefore)) {
    throw new JwtValidationException("Jwt used before not-before time");
}

The Debuggable Demonstration

The SaaS backend must validate three things beyond the defaults: the audience must be "tenant-api", the issuer must be "https://auth.example.com", and the tenant_id claim must be present.

@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .build();

    // Issuer + timestamp validation
    OAuth2TokenValidator<Jwt> defaults = JwtValidators.createDefaultWithIssuer(
        "https://auth.example.com"
    );

    // Audience validation
    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
        "aud",
        aud -> aud != null && aud.contains("tenant-api")
    );

    // Tenant ID presence validation
    OAuth2TokenValidator<Jwt> tenantValidator = new JwtClaimValidator<String>(
        "tenant_id",
        tenantId -> tenantId != null && !tenantId.isBlank()
    );

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        defaults, audienceValidator, tenantValidator
    ));

    return decoder;
}

DelegatingOAuth2TokenValidator runs all validators and collects errors. If any validator fails, it throws JwtValidationException with the combined error messages. This means a token that is both expired and missing a tenant ID produces a response listing both errors.

Timestamp Validation in Detail

The default clock skew is 60 seconds. For the SaaS backend with services distributed across regions, you may need more tolerance:

JwtTimestampValidator timestampValidator = new JwtTimestampValidator(Duration.ofSeconds(30));

Or less, for high-security endpoints where a tight window matters:

JwtTimestampValidator timestampValidator = new JwtTimestampValidator(Duration.ZERO);

Zero clock skew means the token must be valid at the exact instant of evaluation. This breaks if the authorization server’s clock is even slightly ahead of the resource server. In practice, 30 to 60 seconds is the standard range.

The three timestamps in a JWT:

ClaimMeaningRequired by spec
iatIssued atNo
expExpires atNo, but strongly recommended
nbfNot valid beforeNo

Spring’s JwtTimestampValidator checks exp and nbf but not iat. A missing exp means the token never expires. If your authorization server omits exp, add a custom validator:

OAuth2TokenValidator<Jwt> expiryRequired = jwt -> {
    if (jwt.getExpiresAt() == null) {
        return OAuth2TokenValidatorResult.failure(
            new OAuth2Error("missing_exp", "Token must have an expiration", null)
        );
    }
    return OAuth2TokenValidatorResult.success();
};

JWK Set Caching

The RemoteJWKSet cache has two parameters:

  • Lifespan: How long cached keys are considered valid (default: 5 minutes).
  • Refresh timeout: How long to wait for a refresh HTTP call before falling back to cached keys (default: 15 minutes).

When a JWT arrives with an unknown kid, the RemoteJWKSet fetches the JWK Set again regardless of cache age. This is the “cool-down” bypass for key rotation. However, it only retries once. If the kid still does not match after the refresh, signature verification fails.

A key rotation sequence:

  1. Authorization server generates new key pair, kid: "key-2024-07".
  2. Authorization server publishes both old and new keys in the JWK Set.
  3. Authorization server starts signing new tokens with "key-2024-07".
  4. Resource server receives a token with kid: "key-2024-07".
  5. Cache miss. Resource server fetches the JWK Set, finds the new key, verifies the token.
  6. New key is cached. Subsequent tokens with "key-2024-07" use the cache.
  7. Authorization server removes old key "key-2024-06" from the JWK Set after a grace period.
  8. Tokens signed with "key-2024-06" still verify against cached keys until the cache expires.
  9. After cache expiry, the old key is gone. Old tokens fail signature verification.

Step 7 is critical. Removing the old key too early causes valid, non-expired tokens to fail. The grace period must exceed the cache lifespan plus the maximum token lifetime.

The Failure Mode

// BROKEN: no audience validation
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .build();

    // Only default validators: timestamp + issuer
    decoder.setJwtValidator(
        JwtValidators.createDefaultWithIssuer("https://auth.example.com")
    );
    return decoder;
}

This decoder validates the signature, checks the issuer, and verifies timestamps. It does not check the aud (audience) claim.

The attack surface: the same authorization server issues tokens for multiple services. The billing service issues tokens with "aud": "billing-api". The tenant management service issues tokens with "aud": "tenant-api". Without audience validation, a token issued for billing-api is accepted by tenant-api.

A user with access to the billing service but not the tenant management service can use their billing token to call tenant management endpoints. The signature is valid (same authorization server, same keys). The issuer matches. The timestamps are fine. The token was never intended for this service.

This is cross-service token reuse. In a multi-tenant SaaS backend with microservices sharing an authorization server, it is a privilege escalation vector.

The logs show nothing suspicious. The token is valid. Authentication succeeds. The user’s authorities match the roles in the token. The only defense was the audience check, and it was not configured.

The Correct Pattern

// CORRECT: audience validation restricts tokens to this specific service
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .build();

    OAuth2TokenValidator<Jwt> defaults = JwtValidators.createDefaultWithIssuer(
        "https://auth.example.com"
    );

    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
        "aud",
        aud -> aud != null && aud.contains("tenant-api")
    );

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        defaults, audienceValidator
    ));

    return decoder;
}

Now a token with "aud": "billing-api" fails validation at the decoder level. The JwtClaimValidator checks that the aud claim is a list containing "tenant-api". A billing token does not contain this value. The decoder throws JwtValidationException before the token reaches the authentication converter.

The 401 response includes a WWW-Authenticate header:

WWW-Authenticate: Bearer error="invalid_token",
  error_description="An error occurred while attempting to decode the Jwt:
  The aud claim is not valid"

The error is clear, immediate, and happens at the right layer. Authentication fails before authorization is ever evaluated.

Audience validation is not optional in multi-service architectures. It is the difference between a JWT that means “this user exists” and a JWT that means “this user is allowed to call this service.” The aud claim provides the second guarantee. Without it, your resource server trusts every token from the issuer, regardless of intent.