The Root Cause: Spring Security's Default JWT Converter
Integrating Keycloak with Spring Security for role-based access control (RBAC) frequently hits a roadblock: users with assigned roles in Keycloak are denied access with a 403 Forbidden error, despite the role appearing in the Keycloak admin console. The culprit isn't a misconfiguration in Keycloak itself or a user error in assigning roles; it's a fundamental mismatch between how Spring Security's default JwtAuthenticationConverter expects to find user authorities and how Keycloak actually structures them in its JWT tokens.
Spring Security's standard JWT converter is designed to extract authorities from a scope claim (often aliased as scp). This claim typically contains a flat list of scopes. Keycloak, however, often places user roles within a dedicated realm_access.roles or resource_access.<client_id>.roles claim. Without explicit instruction, Spring Security's default converter simply cannot locate these roles, leading to the silent failure of @PreAuthorize("hasRole('ADMIN')") annotations.
This issue is not new and persists across Spring Boot versions, including the latest Spring Boot 3. Developers often find themselves implementing custom converters or workarounds, leading to repetitive code duplication across projects. The problem lies in the expectation mismatch: Spring Security looks for roles in one place, and Keycloak puts them in another.

Understanding Keycloak's Token Structure
To properly configure Spring Security, you must first understand the structure of the JWT tokens issued by Keycloak. When a user authenticates with Keycloak, the issued ID token and access token contain various claims that describe the user and their permissions. The critical claims for role information are typically:
realm_access: This claim is a JSON object containing aroleskey, which is an array of strings representing the roles assigned to the user at the realm level.resource_access: This claim is also a JSON object. It contains keys for each client for which the user has been granted access. Under each client key (e.g.,my-spring-app), there is aroleskey, which is an array of strings representing the roles assigned to the user for that specific client.
The default JwtAuthenticationConverter in Spring Security, by contrast, is hardcoded to look for claims named scope or scp. If these claims are not present or do not contain the expected authority information, Spring Security will not populate the user's authorities correctly.
The Solution: Customizing the JWT Converter
The fix involves creating a custom JwtAuthenticationConverter bean that Spring Security will use instead of the default. This custom converter will be responsible for parsing the Keycloak-specific claims and mapping them to Spring Security's GrantedAuthority objects.
Here's a breakdown of how to implement this:
1. Create a Custom Converter Class
You'll need a class that extends org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter. The key is to override the extractAuthorities method.
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class KeycloakJwtAuthenticationConverter extends JwtAuthenticationConverter {
private static final String REALM_ACCESS = "realm_access";
private static final String ROLES = "roles";
private static final String RESOURCE_ACCESS = "resource_access";
private static final String SCOPE_CLAIM = "scope";
private static final String SCOPE_PREFIX = "SCOPE_";
private static final String ROLE_PREFIX = "ROLE_";
@Override
protected Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
return Stream.concat(
getRealmRoleAuthorities(jwt).stream(),
getResourceRoleAuthorities(jwt).stream()
).collect(Collectors.toSet());
}
private Collection<? extends GrantedAuthority> getRealmRoleAuthorities(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap(REALM_ACCESS);
if (realmAccess == null || !realmAccess.containsKey(ROLES)) {
return Collections.emptyList();
}
return ((Map<String, Object>) realmAccess.get(ROLES)).entrySet().stream()
.map(role -> ROLE_PREFIX + role.getKey().toUpperCase())
.map(authority -> (GrantedAuthority) () -> authority)
.collect(Collectors.toList());
}
private Collection<? extends GrantedAuthority> getResourceRoleAuthorities(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap(RESOURCE_ACCESS);
if (resourceAccess == null) {
return Collections.emptyList();
}
// Assuming you have a specific client ID for your Spring Boot app
// Replace "your-client-id" with your actual Keycloak client ID
String clientId = "your-client-id"; // IMPORTANT: Set your client ID here
Map<String, Object> clientRolesMap = (Map<String, Object>) resourceAccess.get(clientId);
if (clientRolesMap == null || !clientRolesMap.containsKey(ROLES)) {
return Collections.emptyList();
}
return ((Map<String, Object>) clientRolesMap.get(ROLES)).entrySet().stream()
.map(role -> ROLE_PREFIX + role.getKey().toUpperCase())
.map(authority -> (GrantedAuthority) () -> authority)
.collect(Collectors.toList());
}
// Optional: If you also need to parse scopes from a 'scope' claim
private Collection<? extends GrantedAuthority> getScopeAuthorities(Jwt jwt) {
JwtGrantedAuthoritiesConverter src = new JwtGrantedAuthoritiesConverter();
src.setAuthorityPrefix(SCOPE_PREFIX);
return src.convert(jwt);
}
}
2. Configure Spring Security
In your Spring Security configuration class, you need to define a Bean for your custom converter and configure the JwtAuthenticationConverter for the OAuth2ResourceServerConfigurer.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Example using the mapped role
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(customJwtAuthenticationConverter()))
);
return http.build();
}
@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
return new KeycloakJwtAuthenticationConverter();
}
}
Important Considerations:
- Client ID: Ensure you replace
"your-client-id"in theKeycloakJwtAuthenticationConverterwith the actual client ID configured in your Keycloak instance for your Spring Boot application. - Role Prefix: The example code automatically prefixes roles with
ROLE_(e.g.,ADMINbecomesROLE_ADMIN), which aligns with Spring Security's convention forhasRole(). If you usehasAuthority(), you might adjust this or omit the prefix. - Scope vs. Roles: The provided custom converter prioritizes Keycloak's role claims. If your Keycloak setup also uses the
scopeclaim for authorities and you need to combine them, you would need to further modify theextractAuthoritiesmethod to incorporate the logic fromgetScopeAuthorities. - Spring Boot Version: While this approach is robust, minor adjustments to Spring Security configuration might be needed depending on your specific Spring Boot version.
What This Means for You
If you are building or maintaining a Spring Security application secured by Keycloak, this is likely the reason your role-based authorizations are failing. You are not alone; this is a common integration hurdle. By implementing a custom JWT converter, you bridge the gap between Keycloak's token structure and Spring Security's expectations, ensuring that user roles are correctly interpreted and enforced.
This fix is essential for any application relying on Keycloak for user management and authorization. Without it, crucial access control logic will simply not function, leaving your application vulnerable or inaccessible to the intended users. The surprise here is not that a fix is needed, but how often this fundamental structural difference trips up developers, leading to hours of debugging until the specific claim path is identified.
The next step after implementing this is to rigorously test your authorization rules with different user roles and client configurations to confirm that access is granted and denied precisely as intended.
