JwtAuthenticationConverter and Custom Claim Mapping
The Annotation
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new TenantAwareAuthoritiesConverter());
return converter;
}
The decoder proved the token is authentic. Now the converter decides what the token means. This is where claims become authorities, where a JSON object becomes a Spring Security principal, and where your authorization rules get their input data.
The Mechanism
JwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken>. Its convert() method does three things:
- Extracts the principal name from a configurable claim (default:
"sub"). - Delegates to a
Converter<Jwt, Collection<GrantedAuthority>>to extract authorities. - Wraps everything in a
JwtAuthenticationToken.
// JwtAuthenticationConverter.convert() internals
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities =
this.jwtGrantedAuthoritiesConverter.convert(jwt);
String principalClaimValue = jwt.getClaimAsString(this.principalClaimName);
return new JwtAuthenticationToken(jwt, authorities, principalClaimValue);
}
The returned JwtAuthenticationToken extends AbstractOAuth2Token and AbstractAuthenticationToken. It carries the original Jwt object as credentials, the authorities collection, and the principal name. The full Jwt object remains accessible, which means any downstream code can read any claim without needing it mapped to an authority.
Default Authority Mapping
The default JwtGrantedAuthoritiesConverter reads the scope claim from the JWT. If scope is a space-delimited string, it splits on spaces. If the claim name is scp (an alternative used by some providers), it reads that instead. Each scope value is prefixed with SCOPE_.
// Default behavior
JWT claim: "scope": "read write profile"
Authorities: [SCOPE_read, SCOPE_write, SCOPE_profile]
The prefix is configurable via setAuthorityPrefix(). The claim name is configurable via setAuthoritiesClaimName(). But the default converter reads exactly one claim. If your JWT carries roles in a roles claim and permissions in a permissions claim, the default converter ignores both.
The JwtAuthenticationToken
After conversion, the SecurityContext holds a JwtAuthenticationToken. Code accessing it:
@GetMapping("/api/profile")
public Map<String, Object> profile(JwtAuthenticationToken auth) {
Jwt jwt = auth.getToken();
String tenantId = jwt.getClaimAsString("tenant_id");
String username = auth.getName(); // from principal claim
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
return Map.of(
"tenant", tenantId,
"user", username,
"authorities", authorities.stream()
.map(GrantedAuthority::getAuthority).toList()
);
}
This works, but it forces every controller to extract tenant_id from the Jwt manually. The SaaS backend needs tenant context everywhere. A custom Authentication subclass solves this.
The Debuggable Demonstration
Step 1: Custom Authentication Token
public class TenantAuthenticationToken extends JwtAuthenticationToken {
private final String tenantId;
public TenantAuthenticationToken(Jwt jwt,
Collection<? extends GrantedAuthority> authorities,
String name,
String tenantId) {
super(jwt, authorities, name);
this.tenantId = tenantId;
}
public String getTenantId() {
return tenantId;
}
}
This token carries the tenant ID as a first-class field. Controllers receive it directly:
@GetMapping("/api/tenants/settings")
@PreAuthorize("hasRole('ADMIN')")
public TenantSettings getSettings(TenantAuthenticationToken auth) {
return settingsService.getForTenant(auth.getTenantId());
}
No claim extraction in every method. No risk of typos in claim names. The compiler enforces the contract.
Step 2: Custom Converter
public class TenantJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> {
private final TenantAwareAuthoritiesConverter authoritiesConverter =
new TenantAwareAuthoritiesConverter();
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = authoritiesConverter.convert(jwt);
String principal = jwt.getSubject();
String tenantId = jwt.getClaimAsString("tenant_id");
return new TenantAuthenticationToken(jwt, authorities, principal, tenantId);
}
}
Step 3: The Authorities Converter
The SaaS backend’s JWT contains three sources of authorization data:
scope: OAuth2 scopes as a space-delimited string.roles: Application roles as a JSON array.permissions: Fine-grained permissions as a JSON array.
{
"sub": "user-9382",
"tenant_id": "tenant-42",
"scope": "read write",
"roles": ["ADMIN", "BILLING_MANAGER"],
"permissions": ["invoice:create", "invoice:read", "tenant:settings:write"]
}
The converter maps all three:
public class TenantAwareAuthoritiesConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
// 1. Scopes -> SCOPE_xxx
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
for (String s : scope.split(" ")) {
if (!s.isBlank()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + s));
}
}
}
// 2. Roles -> ROLE_xxx
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
// 3. Permissions -> as-is (no prefix)
List<String> permissions = jwt.getClaimAsStringList("permissions");
if (permissions != null) {
for (String perm : permissions) {
authorities.add(new SimpleGrantedAuthority(perm));
}
}
return authorities;
}
}
The resulting authority set for the sample JWT:
SCOPE_read, SCOPE_write,
ROLE_ADMIN, ROLE_BILLING_MANAGER,
invoice:create, invoice:read, tenant:settings:write
Each authority type supports a different authorization pattern:
// Scope-based: OAuth2 resource access control
@PreAuthorize("hasAuthority('SCOPE_write')")
public void createInvoice() { }
// Role-based: coarse-grained access
@PreAuthorize("hasRole('ADMIN')")
public void manageUsers() { }
// Permission-based: fine-grained access
@PreAuthorize("hasAuthority('invoice:create')")
public void createInvoice() { }
Step 4: Wire It Up
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/invoices/**").hasAuthority("invoice:read")
.requestMatchers("/api/**").authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(new TenantJwtAuthenticationConverter())
)
)
.build();
}
Note that .jwtAuthenticationConverter() accepts Converter<Jwt, ? extends AbstractAuthenticationToken>. You are not restricted to JwtAuthenticationConverter. Any converter that returns an AbstractAuthenticationToken subclass works. The TenantJwtAuthenticationConverter returns TenantAuthenticationToken, and Spring Security accepts it without modification.
Tenant Isolation via Method Security
With the tenant ID on the Authentication object, you can enforce tenant isolation at the method level:
@Service
public class InvoiceService {
@PreAuthorize("hasAuthority('invoice:read')")
public List<Invoice> getInvoices(TenantAuthenticationToken auth) {
return invoiceRepository.findByTenantId(auth.getTenantId());
}
@PreAuthorize("hasAuthority('invoice:create') and " +
"#invoice.tenantId == authentication.tenantId")
public Invoice createInvoice(Invoice invoice, TenantAuthenticationToken auth) {
return invoiceRepository.save(invoice);
}
}
The SpEL expression #invoice.tenantId == authentication.tenantId ensures a user cannot create invoices for a different tenant, even if they have the invoice:create permission. The authentication variable in SpEL refers to the Authentication object in the SecurityContext. Since it is a TenantAuthenticationToken, the tenantId property is accessible directly.
The Failure Mode
// BROKEN: converter does not set authorities
public class BrokenJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
String principal = jwt.getSubject();
String tenantId = jwt.getClaimAsString("tenant_id");
// Passes empty authority list
return new TenantAuthenticationToken(
jwt,
Collections.emptyList(), // no authorities
principal,
tenantId
);
}
}
The user authenticates. The SecurityContext has a populated TenantAuthenticationToken. The principal is set. The tenant ID is set. But the authorities collection is empty.
Every authorization check fails:
@PreAuthorize("hasRole('ADMIN')") // DENIED: no ROLE_ADMIN
@PreAuthorize("hasAuthority('SCOPE_read')") // DENIED: no SCOPE_read
@PreAuthorize("isAuthenticated()") // PASSES: authentication exists
The result is a system where every user can access endpoints guarded by isAuthenticated() but no user can access anything protected by role or authority checks. In a SaaS backend, this means authenticated users can call health checks and public profile endpoints but cannot perform any business operation.
The debugging trail is misleading. The HTTP response is 403, not 401. The user is authenticated. The JWT is valid. The claims are present. If you log the Authentication object:
log.info("Auth: principal={}, authorities={}, tenant={}",
auth.getName(),
auth.getAuthorities(),
auth.getTenantId());
// Auth: principal=user-9382, authorities=[], tenant=tenant-42
The empty authorities=[] is the signal. The converter extracted the tenant ID but forgot the authorities. This pattern appears when developers write a custom converter and focus on the custom fields without realizing the framework does not populate authorities automatically when you bypass JwtAuthenticationConverter.
The Correct Pattern
// CORRECT: comprehensive claim-to-authority mapping with tenant context
public class TenantJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> {
private final TenantAwareAuthoritiesConverter authoritiesConverter =
new TenantAwareAuthoritiesConverter();
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
// Authorities from all claim sources
Collection<GrantedAuthority> authorities = authoritiesConverter.convert(jwt);
String principal = jwt.getSubject();
String tenantId = jwt.getClaimAsString("tenant_id");
return new TenantAuthenticationToken(jwt, authorities, principal, tenantId);
}
}
The TenantAwareAuthoritiesConverter handles the mapping. The TenantJwtAuthenticationConverter handles the assembly. Each class has one job. If the claim structure changes (new claim names, different prefixes), you modify the authorities converter. If the authentication token needs new fields (tenant tier, user preferences), you modify the authentication converter.
The composability mirrors what JwtAuthenticationConverter does internally: it delegates authority extraction to a separate converter. When you write your own top-level converter, maintain the same separation. The authorities converter is a pure function from Jwt to Collection<GrantedAuthority>. The authentication converter is a pure function from Jwt to AbstractAuthenticationToken. Test them independently.
@Test
void authoritiesConverterMapsAllClaims() {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "RS256")
.claim("scope", "read write")
.claim("roles", List.of("ADMIN", "BILLING_MANAGER"))
.claim("permissions", List.of("invoice:create"))
.build();
var converter = new TenantAwareAuthoritiesConverter();
Collection<GrantedAuthority> authorities = converter.convert(jwt);
assertThat(authorities).extracting(GrantedAuthority::getAuthority)
.containsExactlyInAnyOrder(
"SCOPE_read", "SCOPE_write",
"ROLE_ADMIN", "ROLE_BILLING_MANAGER",
"invoice:create"
);
}
@Test
void authenticationConverterPreservesTenantId() {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("user-9382")
.claim("tenant_id", "tenant-42")
.claim("scope", "read")
.claim("roles", List.of("ADMIN"))
.build();
var converter = new TenantJwtAuthenticationConverter();
var auth = (TenantAuthenticationToken) converter.convert(jwt);
assertThat(auth.getTenantId()).isEqualTo("tenant-42");
assertThat(auth.getName()).isEqualTo("user-9382");
assertThat(auth.getAuthorities()).isNotEmpty();
}
The test uses Jwt.withTokenValue(), a builder provided by Spring Security for testing. It creates a Jwt object without needing a real signed token. No key pairs, no JWK Sets, no HTTP calls. The converter does not care whether the JWT was decoded from a real token or built in a test. It receives a Jwt object and maps it. This is why the separation matters: the converter is testable in isolation from the entire OAuth2 infrastructure.