JWT Resource Server Internals: JwtDecoder, Claim Extraction, and the Converter Chain
The Annotation
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.build();
}
}
That .jwt() call installs five components into the filter chain. You see two of them. The framework creates the other three. This chapter traces the full path a JWT takes from the Authorization header to a populated SecurityContext.
The Mechanism
A JWT resource server does not issue tokens. It validates tokens issued by something else, typically an OAuth2 authorization server. Spring Security’s resource server support is a pipeline of four stages.
Stage 1: Token Extraction
BearerTokenAuthenticationFilter sits in the filter chain. On every request, it calls BearerTokenResolver.resolve(HttpServletRequest). The default resolver, DefaultBearerTokenResolver, looks for the Authorization header with a Bearer prefix. If found, it strips the prefix and wraps the raw token string in a BearerTokenAuthenticationToken.
// Inside BearerTokenAuthenticationFilter.doFilterInternal()
String token = this.bearerTokenResolver.resolve(request);
if (token == null) {
filterChain.doFilter(request, response); // no token, continue chain
return;
}
BearerTokenAuthenticationToken authenticationRequest =
new BearerTokenAuthenticationToken(token);
No token means no authentication attempt. The filter passes the request through. If a secured endpoint receives the request later without authentication, AuthenticationEntryPoint returns 401.
Stage 2: Authentication Manager Dispatch
The filter calls AuthenticationManager.authenticate(authenticationRequest). The ProviderManager iterates its list of AuthenticationProvider instances. JwtAuthenticationProvider supports BearerTokenAuthenticationToken, so it handles the request.
Stage 3: Decode and Validate
JwtAuthenticationProvider calls JwtDecoder.decode(token). The decoder does two things: parse the JWT structure (header, payload, signature) and validate it (signature verification, timestamp checks, claim constraints). The result is a Jwt object containing the parsed claims.
// Inside JwtAuthenticationProvider.authenticate()
Jwt jwt = this.jwtDecoder.decode(bearer.getToken());
If decoding fails, a JwtException is thrown. The provider wraps this in a BadCredentialsException with InvalidBearerTokenException. The filter catches it and delegates to AuthenticationEntryPoint, which returns 401 with a WWW-Authenticate header describing the error.
Stage 4: Convert to Authentication
After successful decoding, the provider calls JwtAuthenticationConverter.convert(jwt). This converter maps the Jwt object to an AbstractAuthenticationToken, extracting authorities from claims and building the principal. The result is stored in the SecurityContext.
// Inside JwtAuthenticationProvider.authenticate()
AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
The full call chain:
HTTP Request
-> BearerTokenAuthenticationFilter.doFilterInternal()
-> DefaultBearerTokenResolver.resolve()
-> ProviderManager.authenticate()
-> JwtAuthenticationProvider.authenticate()
-> NimbusJwtDecoder.decode() // validate + parse
-> JwtAuthenticationConverter.convert() // map to Authentication
-> SecurityContextHolder.setAuthentication()
The Debuggable Demonstration
The SaaS backend receives JWTs issued by an external authorization server. Each token contains a tenant_id claim, a roles claim with application-level roles, and the standard scope claim.
A sample JWT payload:
{
"sub": "user-9382",
"iss": "https://auth.example.com",
"aud": "tenant-api",
"tenant_id": "tenant-42",
"roles": ["ADMIN", "BILLING_MANAGER"],
"scope": "read write",
"iat": 1716000000,
"exp": 1716003600
}
The resource server configuration:
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/billing/**").hasRole("BILLING_MANAGER")
.requestMatchers("/api/**").authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.build();
}
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
OAuth2TokenValidator<Jwt> defaults = JwtValidators.createDefaultWithIssuer(
"https://auth.example.com"
);
OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("tenant-api")
);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(defaults, audience));
return decoder;
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new TenantAwareAuthoritiesConverter());
converter.setPrincipalClaimName("sub");
return converter;
}
}
The custom authorities converter:
public class TenantAwareAuthoritiesConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>();
// Map scopes to SCOPE_xxx
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
for (String s : scope.split(" ")) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + s));
}
}
// Map roles to ROLE_xxx
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
return authorities;
}
}
With this configuration, a request with the sample JWT produces an Authentication with authorities: SCOPE_read, SCOPE_write, ROLE_ADMIN, ROLE_BILLING_MANAGER. The .hasRole("ADMIN") check passes because Spring Security strips the ROLE_ prefix when evaluating hasRole().
JWK Set URI and Key Rotation
The NimbusJwtDecoder retrieves the JSON Web Key Set from the configured URI on first use and caches it. When a JWT arrives with a kid (Key ID) header that does not match any cached key, the decoder fetches the JWK Set again. This is the key rotation mechanism: the authorization server publishes a new key, starts signing tokens with it, and the resource server picks it up automatically on the next cache miss.
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
The cache behavior is controlled by Nimbus JOSE. The default JWKSetCache uses a 5-minute lifespan and 15-minute refresh timeout. For production systems with strict key rotation requirements, you can customize this:
@Bean
JwtDecoder jwtDecoder() {
RestOperations rest = new RestTemplate();
// Custom cache: 5-minute lifespan, 1-minute refresh ahead
JWKSetCache cache = new DefaultJWKSetCache(
Duration.ofMinutes(5).toMillis(),
Duration.ofMinutes(1).toMillis(),
TimeUnit.MILLISECONDS
);
JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(
URI.create(jwkSetUri).toURL(),
new DefaultResourceRetriever(),
cache
);
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
The Failure Mode
// BROKEN: only maps "scope" claims, ignores custom role claims
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
// Uses the default JwtGrantedAuthoritiesConverter
// which ONLY maps the "scope" claim to SCOPE_xxx authorities
return converter;
}
The default JwtGrantedAuthoritiesConverter reads the scope claim (or scp as a fallback) and prefixes each value with SCOPE_. It ignores everything else. The roles claim in the JWT is never read.
With this configuration, the user authenticates successfully. The SecurityContext contains an Authentication with authorities SCOPE_read and SCOPE_write. But .hasRole("ADMIN") fails. Every @PreAuthorize("hasRole('ADMIN')") endpoint returns 403.
The symptom is confusing: the user is authenticated (200 on public endpoints) but always denied on role-protected endpoints. If you inspect the Authentication object:
@GetMapping("/api/debug/auth")
public Map<String, Object> debugAuth(Authentication auth) {
return Map.of(
"principal", auth.getName(),
"authorities", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList(),
"authenticated", auth.isAuthenticated()
);
}
// Returns: {"principal":"user-9382","authorities":["SCOPE_read","SCOPE_write"],"authenticated":true}
// No ROLE_ADMIN, no ROLE_BILLING_MANAGER
The roles exist in the JWT. They are parsed into the Jwt object. They are never extracted into authorities because no converter asks for them.
The Correct Pattern
// CORRECT: maps both scope and custom role claims
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Set<GrantedAuthority> authorities = new HashSet<>();
// Scopes
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
Arrays.stream(scope.split(" "))
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.forEach(authorities::add);
}
// Roles
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add);
}
return authorities;
});
converter.setPrincipalClaimName("sub");
return converter;
}
This converter produces the full authority set. hasRole("ADMIN") now matches ROLE_ADMIN. The key insight: JwtAuthenticationConverter is a composition point. The framework gives you the parsed Jwt. You decide what goes into the Authentication. If your authorization server puts roles in a non-standard claim, you must write the mapping. The framework will not guess.
For the tenant context, the converter above handles authorities. The next section covers extracting tenant_id into a custom Authentication subclass that carries tenant information alongside the standard principal and authorities.