Introduction
Your Spring Boot application sits behind a corporate OAuth2 provider or a public OIDC endpoint, and you need to validate incoming access tokens without becoming a bottleneck. Spring Boot as an OAuth2 resource server for production gives you the tools to validate tokens from any OIDC provider, enforce scopes, and cache signing keys—all with minimal configuration and maximum reliability.
In this article, we'll walk through setting up Spring Boot to act as a resource server in a production environment. We'll cover how issuer URIs enable automatic key discovery, why audience validation matters, how Spring's built-in JWKS caching prevents unnecessary network calls, and how to translate OAuth2 scopes into Spring Security authorities. By the end, you'll have a clear picture of how to secure your microservices the right way.
Issuer URI enables key discovery
When your Spring Boot resource server receives a JWT, the first question is: can we trust it? The answer lies in verifying the signature using the issuer's public keys. Rather than hardcoding key URLs or manually fetching them, OIDC defines a standard discovery mechanism: the issuer URI.
An OIDC provider publishes metadata at https://your-issuer/.well-known/openid-configuration. This metadata includes the jwks_uri endpoint where public keys live. Spring Security's JwtDecoders.fromIssuerLocation() automates this discovery: it fetches the metadata, extracts the JWKS endpoint, and wires everything up.
Here's the simplest configuration:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(JwtDecoders.fromIssuerLocation(
"https://auth.example.com"
))
)
);
return http.build();
}
}
That's it. Spring will:
- Fetch metadata from
https://auth.example.com/.well-known/openid-configuration - Extract the
jwks_uri(typicallyhttps://auth.example.com/oauth/authorize/jwks) - Retrieve and cache public keys
- Validate incoming JWT signatures automatically
In production, we've used this pattern with Auth0, Okta, AWS Cognito, and custom OIDC providers. The key benefit: no manual key management, no string configuration for URLs that might change. The issuer URI is your single source of truth.
Validate audience as well as signature
Signature validation ensures the token wasn't tampered with—but it doesn't guarantee the token was meant for your service. Enter the aud (audience) claim. When a token includes an audience, you should verify that your application is listed in it.
Consider a microservice architecture where an authorization server issues tokens for multiple consumers. Without audience validation, a token issued for the payment service could be used to call the inventory service. Audience validation acts as a firewall.
Spring's JWT decoder doesn't validate audience by default. You need to add a custom validator:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(
"https://auth.example.com"
);
OAuth2TokenValidator audienceValidator =
new JwtClaimValidator<>(JwtClaimNames.AUD,
aud -> aud instanceof Collection &&
((Collection) aud).contains("my-api-id")
);
OAuth2TokenValidator withIssuer =
JwtValidators.createDefaultWithIssuer("https://auth.example.com");
OAuth2TokenValidator validator =
new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
decoder.setJwtValidator(validator);
return decoder;
}
}
The JwtClaimValidator checks that the aud claim includes your API ID. If validation fails, the request is rejected before it reaches your controllers. In practice, we've caught token misuse at the boundary with this single line of defense.
Cache JWKS keys — Spring does this
Every request to your resource server triggers JWT validation, which requires fetching and parsing the signing key. Without caching, you'd hit the issuer's JWKS endpoint for every single token. That's slow, unreliable if the network hiccups, and puts unnecessary load on the authorization server.
The good news: Spring's NimbusJwtDecoder (the default implementation) caches JWKS keys automatically. It uses an in-memory cache with a configurable TTL. By default, keys are cached for 5 minutes.
You can customize the cache behavior by working with the decoder's underlying Nimbus instance:
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(
"https://auth.example.com"
);
JWTProcessor processor =
decoder.getNimbusJwtProcessor();
// Access and configure the JWKS source if needed
// (Cache is already enabled with default TTL)
return decoder;
}
In most cases, the default cache is sufficient. We've observed that in high-traffic environments (thousands of requests per second), the cache hit rate exceeds 99%. Keys are only refetched when they genuinely rotate at the issuer—typically weeks or months apart.
One production tip: monitor your logs for JWKS fetch errors. If the issuer is temporarily unreachable, cached keys become stale. Spring will eventually reject tokens, but you want visibility into these events before users complain. Set up alerting on outbound HTTPS calls to your issuer's JWKS endpoint.
Scopes become SCOPE_ authorities
OAuth2 scopes define what a token can do: read:posts, write:orders, etc. When Spring Boot validates a token with scopes, it translates each scope into a Spring Security authority with the prefix SCOPE_. This bridges OAuth2 and Spring's authorization model.
If your token includes scopes ["read:posts", "write:posts"], Spring creates authorities SCOPE_read:posts and SCOPE_write:posts. You can then use these in method security or URL-based access control.
@RestController
@RequestMapping("/api/posts")
public class PostController {
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_read:posts')")
public List listPosts() {
return postService.findAll();
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_write:posts')")
public Post createPost(@RequestBody Post post) {
return postService.save(post);
}
}
First, enable method security:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
Now, @PreAuthorize checks the scope-derived authority before executing the method. A request with only read:posts can call listPosts() but not createPost(). Spring enforces this at runtime.
In URL-based access control, you can use the same pattern:
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("GET", "/api/posts").hasAuthority("SCOPE_read:posts")
.requestMatchers("POST", "/api/posts").hasAuthority("SCOPE_write:posts")
.anyRequest().authenticated()
);
A critical nuance: the colon in read:posts is literal. Spring doesn't interpret it as hierarchy. If your issuer uses hierarchical scopes (e.g., admin implies user), you need custom logic to map them. We'll typically implement a custom GrantedAuthoritiesConverter for that:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("SCOPE_");
authoritiesConverter.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
// Then wire it into your JWT decoder:
// decoder.setJwtAuthenticationConverter(jwtAuthenticationConverter());
Common mistakes
Forgetting audience validation: Many teams hardcode the issuer but skip audience checks. This works for single-service deployments, but in a microservice mesh, it's a security gap. Always validate that the token was meant for your service.
Assuming scope names are hierarchical: Spring's authority system supports hierarchies (e.g., ADMIN > USER), but OAuth2 scopes don't. SCOPE_write:posts and SCOPE_read:posts are unrelated. If you need hierarchy, build it into your access control logic or the authorization server itself.
Not handling clock skew: JWTs include an exp (expiration) claim. If your server's clock is out of sync with the issuer's, tokens might be rejected prematurely. Set your system to sync via NTP and consider a small clock skew tolerance in production (Spring can configure this).
Ignoring JWKS cache failures: If the issuer is down and keys aren't cached, your resource server stops working. Test this scenario—have a plan (circuit breaker, graceful degradation, or failover).
Hardcoding issuer URLs: Use environment variables or config servers for issuer URIs. They change between environments and might change at runtime. Hardcoding defeats the flexibility of OAuth2.
Key takeaways
- Use
JwtDecoders.fromIssuerLocation()to let Spring discover JWKS endpoints automatically via OIDC metadata. No manual URL management. - Always validate the
aud(audience) claim to ensure tokens are intended for your service, not other consumers. - Spring's
NimbusJwtDecodercaches JWKS keys by default; no configuration needed. Verify your cache is working in production logs. - OAuth2 scopes map to Spring authorities with the
SCOPE_prefix. Use@PreAuthorizeandhasAuthority()to enforce fine-grained access control. - Externalize your issuer URI, configure audience validation, and test clock skew and cache failure scenarios before deploying to production.






Comments
Loading comments…