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
BadCredentialsExceptionorJwtExceptionrelated 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
JwtDecoderto use the correct JWKS URI.
Why it works: The@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(); } }NimbusJwtDecoder(a popular JWT library) fetches the public keys from the specified JWKS URI. When a JWT arrives, it looks at thekidin 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 issueror similar. - Common Cause: The
issclaim 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.
Why it works: After signature validation, the@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; }JwtDecoderchecks if theissvalue in the token payload matches theexpectedIssuerUriyou’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 audienceor similar. - Common Cause: The JWT was issued for a different client or resource, and your resource server isn’t listed in its
audclaim. - Fix: Configure the expected audience.
Why it works: The@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; }JwtDecoderchecks if theaudclaim in the JWT payload contains the audience string you’ve specified. If theaudclaim 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 expiredor similar. - Common Cause: The token’s
exptimestamp 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.
Why it works: The// 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; }expclaim is a Unix timestamp indicating when the token expires. TheJwtDecoderautomatically compares this to the current time, adjusted by theclockSkewif 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
subclaim, or it contains an identifier that your system doesn’t recognize or allows. - Fix: Spring Security automatically extracts the
subclaim and uses it asauthentication.getName(). If you need to perform custom validation on thesubclaim itself (e.g., check if it exists in your user database), you’d typically do that within yourAuthenticationManageror by customizing theJwtAuthenticationConverter.
Why it works: The@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 // ) // ) // ...JwtAuthenticationConverteris invoked after the JWT is decoded and validated. It allows you to map JWT claims (likesub,scope,roles) to Spring Security’sGrantedAuthorityobjects 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 JTIor similar. - Common Cause: A token with a previously used
jtiis being presented. This requires the resource server to maintain a cache of recently used JTI values. - Fix: Implement JTI validation using a cache.
Why it works: By maintaining a set of used// 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; }jtivalues 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.