OAuth 2.0 Resource Server
Validating tokens correctly: NimbusJwtDecoder configuration, issuer and audience validators, mapping claims to authorities, opaque token introspection and multi-tenant decoding.
On this page
A resource server's entire job is deciding whether a token is valid and what it permits. Both halves have well-known ways to get subtly wrong, and both fail silently — an incorrectly validated token still looks like a validated one.
Key Takeaways
- Validate signature, issuer, audience and expiry. A signature alone is not enough.
- Configure the authorities converter or your role checks will never match.
- Opaque tokens trade a network call per request for instant revocation.
- Multi-tenancy means routing to the right decoder by issuer, never trusting the claim blindly.
- The resource server validates independently of any gateway.
Decoder configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain api(HttpSecurity http, JwtAuthenticationConverter converter)
throws Exception {
http
.securityMatcher("/api/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/**").hasAuthority("SCOPE_orders.write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(converter)))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(problemDetailEntryPoint())
.accessDeniedHandler(problemDetailAccessDeniedHandler()));
return http.build();
}
@Bean
JwtDecoder jwtDecoder(@Value("${app.auth.issuer}") String issuer,
@Value("${app.auth.audience}") String audience) {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
// exp and nbf, with tolerance for clock skew between hosts.
new JwtTimestampValidator(Duration.ofSeconds(60)),
new JwtIssuerValidator(issuer),
// Without this, a token minted for the reporting service is
// happily accepted by the payments service.
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains(audience))));
return decoder;
}
}JwtDecoders.fromIssuerLocation fetches the discovery document and configures the JWKS URI and
algorithm automatically, which removes a class of configuration drift between environments.
It also sets up JWKS caching, which is better understood before an incident than during one. The
decoder fetches the key set lazily, caches it for five minutes, and refetches when it encounters a
kid it does not recognise. That combination makes ordinary key rotation invisible — but it also puts
the authorization server's JWKS endpoint on the critical path for the first request after a rotation,
and if it is unreachable at that moment every token fails to validate. Pin the algorithms you accept
too: a decoder built from discovery accepts everything the issuer advertises, which is usually broader
than you need.
The audience validator is the one most often omitted, and its absence is a real vulnerability in any estate with more than one resource server: a token obtained legitimately for one service becomes a credential for all of them.
Authorities
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthoritiesClaimName("scope");
scopes.setAuthorityPrefix("SCOPE_");
var converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("sub");
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var authorities = new ArrayList<GrantedAuthority>(scopes.convert(jwt));
// Roles live under a different claim and need the ROLE_ prefix,
// because hasRole('ADMIN') expands to ROLE_ADMIN.
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add);
}
return authorities;
});
return converter;
}Keep the distinction clear. A scope is what the application was authorised to do on the user's
behalf. A role is what the user is. hasAuthority("SCOPE_orders.write") checks the first;
hasRole("ADMIN") checks the second. Conflating them produces rules that look right and permit the
wrong thing.
Opaque tokens
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth.acme.com/oauth2/introspect
client-id: ${INTROSPECTION_CLIENT_ID}
client-secret: ${INTROSPECTION_CLIENT_SECRET}@Bean
OpaqueTokenIntrospector cachingIntrospector(OAuth2ResourceServerProperties props) {
var delegate = new SpringOpaqueTokenIntrospector(
props.getOpaquetoken().getIntrospectionUri(),
props.getOpaquetoken().getClientId(),
props.getOpaquetoken().getClientSecret());
// Without caching, every request becomes a synchronous call to the auth
// server — which then becomes a single point of failure for everything.
return token -> cache.get(token, key -> delegate.introspect(key));
}Cache TTL is the trade-off dial. Long caching restores JWT-like performance and delays revocation by the TTL; short caching honours revocation quickly and puts load on the authorization server. Thirty to sixty seconds is a common compromise.
One caution on that cache: key it on a hash of the token rather than the token itself, and bound its size. A map keyed on raw bearer tokens is a store of live credentials sitting in heap, and it will appear in full in any heap dump taken for an unrelated reason.
Multi-tenancy
@Bean
JwtDecoder multiTenantDecoder(TenantProperties tenants) {
// The allowlist is the security boundary. Reading iss and fetching that
// issuer's JWKS would let an attacker sign tokens with their own key.
Map<String, JwtDecoder> decoders = tenants.issuers().stream()
.collect(toMap(identity(), issuer -> {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(),
new JwtIssuerValidator(issuer)));
return decoder;
}));
return new JwtIssuerAuthenticationManagerResolver(decoders::get) instanceof var resolver
? token -> decoderFor(token, decoders).decode(token)
: null;
}Spring's JwtIssuerAuthenticationManagerResolver does this properly when given a map or a
trustedIssuers collection. What it must never do is fetch keys from whatever issuer the token
names — that is a complete authentication bypass, and it has appeared in real systems.
Errors that match your API
@Bean
AuthenticationEntryPoint problemDetailEntryPoint() {
return (request, response, ex) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/problem+json");
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
response.getWriter().write("""
{"type":"https://api.acme.com/errors/unauthenticated",
"title":"Unauthenticated","status":401,
"detail":"A valid bearer token is required."}
""");
};
}Security filters run before the DispatcherServlet, so @ControllerAdvice never sees these
exceptions. Without explicit handlers, an unauthenticated call returns an empty body or an HTML error
page while every other error in your API returns RFC 7807 JSON.
Keep the detail generic. "Token expired at 09:14" tells an attacker their token was otherwise valid, which is more than they need to know.
Why validate again behind a gateway
A gateway that checks tokens is a convenience, not a boundary. Plenty of things reach the service without passing through it: a misrouted internal call, a second ingress added six months later, a sidecar, a colleague's debugging script against the pod. Treating the gateway's check as sufficient means the service's real access-control policy is a network diagram — and network diagrams change without anyone reopening the security review.
The cost of validating locally is a signature check against an already-cached key, measured in microseconds. That buys you a rule enforced where the data actually lives, which is the only place it cannot be routed around.
What to take away
Validate issuer and audience alongside the signature and expiry. Configure the authorities converter
explicitly and keep scopes and roles conceptually separate. Cache introspection if you use opaque
tokens. For multi-tenancy, resolve the decoder from an allowlist — never from the token. And add
ProblemDetail handlers so auth errors look like the rest of your API.
Frequently Asked Questions
Is validating the signature enough?
JWT or opaque tokens?
Why does hasRole not match my token roles?
Related tutorials
- Spring Authorization ServerRunning your own OAuth 2.1 and OIDC provider: registering clients, persisting authorizations, JWK sources and key rotation, custom claims, and the consent page.
- OpenID Connect (OIDC)The identity layer on OAuth 2.0: what an ID token is and how to validate it, standard scopes and claims, discovery, and single logout across relying parties.
- OAuth 2.0 — The Complete GuideOAuth 2.0 without the confusion: the four actors, the grants that still matter, why PKCE is mandatory, refresh token rotation, and what OAuth 2.1 removed.
- Social Login — Google, GitHub, MicrosoftAdding sign in with Google, GitHub and Microsoft: client registration, mapping provider profiles to your user model, safe account linking, and the onboarding flow afterwards.