Spring Boot’s OAuth2 Resource Server doesn’t actually validate JWTs by default; it just blindly trusts them if they look like JWTs.

Let’s see how a resource server handles an incoming request with a JWT. Imagine we have a simple endpoint:

@RestController
public class MessageController {

    @GetMapping("/api/message")
    public String getMessage(JwtAuthenticationToken authentication) {
        // This 'authentication' object is populated by Spring Security
        // if the JWT is present and looks valid (but we'll add actual validation next).
        return "Hello, " + authentication.getName() + "!";
    }
}

And our SecurityFilterChain configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults()) // This is where the magic (or lack thereof) happens initially
            );
        return http.build();
    }
}

If you send a request to /api/message with any validly formatted JWT in the Authorization: Bearer <some_jwt> header, this endpoint will work, even if the JWT was issued by a completely different, untrusted authority. That’s because oauth2.jwt(Customizer.withDefaults()) by itself sets up a JwtDecoder that can parse JWTs but doesn’t enforce signature validation or issuer checks. It’s like having a lock that only checks if the key has the right shape, not if it’s the actual key to this door.

The problem is that Customizer.withDefaults() for jwt() in oauth2ResourceServer is a convenience that doesn’t include the crucial validation steps. It sets up a JwtDecoder that can parse the JWT structure but doesn’t verify its authenticity against a trusted source. Without proper validation, your resource server is vulnerable to forged tokens.

Here’s how to properly configure JWT validation, focusing on the most common and critical checks:

1. Verifying the JWT Signature: This is paramount. You need to ensure the token hasn’t been tampered with. This is typically done by trusting the public key of the issuer.

  • Diagnosis: If you see BadCredentialsException or JwtException related to signature validation, it means the token’s signature doesn’t match the public key you’ve provided.
  • Common Cause: The public key configured in your resource server doesn’t match the private key used by the authorization server to sign the JWT.
  • Diagnosis Command/Check: Inspect the kid (Key ID) header in the JWT. Then, query the authorization server’s JWKS (JSON Web Key Set) endpoint (usually /.well-known/jwks.json) to retrieve the corresponding public key.
  • Fix: Configure your JwtDecoder to use the correct JWKS URI.
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
        private String jwkSetUri;
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                .authorizeHttpRequests(authorize -> authorize
                    .requestMatchers("/api/**").authenticated()
                    .anyRequest().permitAll()
                )
                .oauth2ResourceServer(oauth2 -> oauth2
                    .jwt(jwtConfigurer -> jwtConfigurer
                        .decoder(jwtDecoder()) // Use our custom decoder
                    )
                );
            return http.build();
        }
    
        @Bean
        public JwtDecoder jwtDecoder() {
            // This decoder will automatically fetch public keys from the JWKS URI
            // and validate the signature using the 'kid' from the JWT header.
            return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
        }
    }
    
    Why it works: The NimbusJwtDecoder (a popular JWT library) fetches the public keys from the specified JWKS URI. When a JWT arrives, it looks at the kid in the JWT header, finds the matching public key in the downloaded JWKS, and uses it to verify the signature.

2. Validating the Issuer (iss claim): Ensure the token was issued by a trusted authority.

  • Diagnosis: JwtValidationException: Invalid issuer or similar.
  • Common Cause: The iss claim in the JWT doesn’t match the expected issuer URI configured for your resource server. This can happen if you’re using a shared JWKS endpoint for multiple authorization servers, or if the token is from a completely different system.
  • Fix: Configure the expected issuer URI.
    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
        // Set the expected issuer. The token's 'iss' claim MUST match this.
        decoder.setIssuer(expectedIssuerUri);
        return decoder;
    }
    
    Why it works: After signature validation, the JwtDecoder checks if the iss value in the token payload matches the expectedIssuerUri you’ve set. If they don’t match, the token is rejected.

3. Validating the Audience (aud claim): Ensure the token was intended for your specific resource server (or API).

  • Diagnosis: JwtValidationException: Invalid audience or similar.
  • Common Cause: The JWT was issued for a different client or resource, and your resource server isn’t listed in its aud claim.
  • Fix: Configure the expected audience.
    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
        decoder.setIssuer(expectedIssuerUri);
        // Set the expected audience. The token's 'aud' claim MUST contain this value.
        // If 'aud' is a JSON array, it must contain at least one of the values.
        decoder.setAudience("my-resource-server-id");
        return decoder;
    }
    
    Why it works: The JwtDecoder checks if the aud claim in the JWT payload contains the audience string you’ve specified. If the aud claim is an array, it checks if any of the values in the array match your configured audience.

4. Checking Token Expiration (exp claim): Prevent the use of expired tokens.

  • Diagnosis: JwtValidationException: Token has expired or similar.
  • Common Cause: The token’s exp timestamp has passed. This is usually not a configuration issue but an operational one (e.g., client is holding onto tokens too long).
  • Fix: Ensure your authorization server issues tokens with reasonable expiration times, and your clients refresh them promptly. Spring Security handles this automatically as part of JWT validation. No explicit configuration is usually needed unless you want to adjust clock skew tolerance.
    // If you need to account for clock drift between servers
    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
        decoder.setIssuer(expectedIssuerUri);
        decoder.setAudience("my-resource-server-id");
        // Allow for a small clock skew (e.g., 60 seconds)
        decoder.setClockSkew(java.time.Duration.ofSeconds(60));
        return decoder;
    }
    
    Why it works: The exp claim is a Unix timestamp indicating when the token expires. The JwtDecoder automatically compares this to the current time, adjusted by the clockSkew if configured.

5. Validating the Subject (sub claim): While not always strictly required for authentication, it’s good practice to ensure the subject is present and valid if your application logic depends on it.

  • Diagnosis: JwtValidationException: Invalid subject (less common, as Spring Security usually just uses it if present).
  • Common Cause: The JWT is missing the sub claim, or it contains an identifier that your system doesn’t recognize or allows.
  • Fix: Spring Security automatically extracts the sub claim and uses it as authentication.getName(). If you need to perform custom validation on the sub claim itself (e.g., check if it exists in your user database), you’d typically do that within your AuthenticationManager or by customizing the JwtAuthenticationConverter.
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        // This converter can be used to transform JWT claims into authorities
        // and potentially perform custom validation on claims like 'sub'.
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        // You can customize how authorities are extracted from claims here.
        // For simple 'sub' validation, you might add a check before returning.
    
        // Example: Simple check if 'sub' is present
        return new JwtAuthenticationConverter() {
            @Override
            protected Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
                if (jwt.getSubject() == null || jwt.getSubject().isEmpty()) {
                    throw new IllegalArgumentException("JWT subject ('sub') is missing or empty.");
                }
                return grantedAuthoritiesConverter.convert(jwt);
            }
        };
    }
    
    // Then in SecurityConfig:
    // ...
    // .oauth2ResourceServer(oauth2 -> oauth2
    //     .jwt(jwtConfigurer -> jwtConfigurer
    //         .decoder(jwtDecoder())
    //         .jwtAuthenticationConverter(jwtAuthenticationConverter()) // Use our custom converter
    //     )
    // )
    // ...
    
    Why it works: The JwtAuthenticationConverter is invoked after the JWT is decoded and validated. It allows you to map JWT claims (like sub, scope, roles) to Spring Security’s GrantedAuthority objects and perform additional checks on the claims themselves.

6. Validating Token ID (jti claim): Prevent replay attacks by ensuring each token is used only once.

  • Diagnosis: JwtValidationException: Invalid JTI or similar.
  • Common Cause: A token with a previously used jti is being presented. This requires the resource server to maintain a cache of recently used JTI values.
  • Fix: Implement JTI validation using a cache.
    // You'll need a cache implementation, e.g., Guava Cache or Caffeine
    // For simplicity, let's imagine a basic in-memory set with TTL
    private Set<String> usedJtis = Collections.synchronizedSet(new HashSet<>());
    private static final long MAX_JTI_AGE_MS = 5 * 60 * 1000; // 5 minutes
    
    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
        decoder.setIssuer(expectedIssuerUri);
        decoder.setAudience("my-resource-server-id");
        decoder.setClockSkew(java.time.Duration.ofSeconds(60));
    
        // Customizer to add JTI validation
        decoder.setJwtValidator(jwtValidator -> {
            // Perform default validations first
            BaseJwtValidator defaultValidator = new OAuth2TokenValidator<Jwt>() {
                @Override
                public Mono<Map<String, Object>> validate(Jwt token) {
                    // This is a simplified representation; NimbusJwtDecoder has its own internal validators
                    // In a real scenario, you'd likely extend OAuth2TokenValidator and combine validators.
                    return Mono.just(token.getClaims());
                }
            };
    
            // Custom JTI validation logic
            String jti = jwtValidator.getClaimAsString("jti");
            if (jti != null) {
                // Check if JTI has been used recently
                if (usedJtis.contains(jti)) {
                    throw new JwtValidationException("JTI has already been used.", Collections.singletonList(new Error("invalid_token", "JTI replay detected")));
                }
                // Add JTI to the set and schedule removal (simplified)
                usedJtis.add(jti);
                // In a real app, you'd use a cache with eviction policies
                // e.g., scheduled task to remove old JTIs
                // For demonstration, let's assume a mechanism to remove after MAX_JTI_AGE_MS
            }
            return defaultValidator.validate(jwtValidator);
        });
    
        return decoder;
    }
    
    Why it works: By maintaining a set of used jti values and checking incoming tokens against it, you prevent the same token from being successfully processed multiple times within a short period, mitigating replay attacks. A proper implementation would use a time-based cache (like Caffeine or Guava) for efficient storage and automatic eviction of old JTIs.

After implementing these validations, your resource server will robustly verify incoming JWTs, significantly enhancing your application’s security.

The next error you’ll likely encounter is a 401 Unauthorized response if any of these validations fail, which is the desired security outcome.

Want structured learning?

Take the full Spring-boot course →