It happens to every team migrating to Keycloak. You've set up your realm, created roles, and assigned them to your users. You configure your Spring Boot application as an OAuth2 Resource Server. You log in, get a valid JWT, and hit your secured endpoint.
403 Forbidden.
You decode the JWT, and the roles are right there. Yet Spring Security acts as if your user has no privileges at all. Here's why this happens, why it often breaks only in production, and how to fix it cleanly.
The symptom: 403 despite a valid token
Let's say you secure an endpoint expecting an admin role:
@GetMapping("/api/admin")
@PreAuthorize("hasRole('admin')")
public String adminOnly() {
return "Secret data";
}
You decode your Keycloak access token (e.g. using jwt.io) and see this payload:
{
"realm\_access": {
"roles": \[
"admin",
"user"
]
},
"resource\_access": {
"my-client": {
"roles": \[
"manager"
]
}
},
"preferred\_username": "jihed"
}
The role is right there. So why the 403?
Why it breaks: the GrantedAuthority prefix
Spring Security relies on a JwtAuthenticationConverter to translate the incoming JWT into an Authentication object. By default, Spring looks for a claim named scope or scp. If it finds one, it extracts the values and prefixes them with SCOPE\_.
If you want roles instead, you might have tried setting a custom claim name — but the default converter still expects a flat array of strings, and prefixes whatever it finds with ROLE\_.
Keycloak nests roles inside realm\_access.roles (realm-wide roles) and resource\_access.<client\_id>.roles (client-specific roles). Spring Security's default converter has no idea how to traverse that nested structure. It sees no roles, grants no authorities, and denies access.
The realm vs. resource trap
A common pitfall: fixing this for realm roles while ignoring client roles.
In development, teams often use realm roles because they're easier to assign globally. They write a quick converter that reads realm\_access.roles, and everything works.
Then comes production. Security policy dictates that roles must be scoped to the specific application (client roles) rather than the entire realm. The infrastructure team provisions client roles under resource\_access.my-client.roles. The application deploys, receives the new tokens, and fails with 403 because the custom converter only ever looked at the realm level.
The fix: a custom JwtAuthenticationConverter
To solve this robustly, you need a converter that extracts both realm and resource roles, applies the ROLE\_ prefix, and merges them into a single collection of GrantedAuthority objects:
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class KeycloakRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
private final String clientId;
public KeycloakRoleConverter(String clientId) {
this.clientId = clientId;
}
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Stream<String> realmRoles = extractRealmRoles(jwt);
Stream<String> clientRoles = extractClientRoles(jwt, this.clientId);
return Stream.concat(realmRoles, clientRoles)
.map(role -> new SimpleGrantedAuthority("ROLE\_" + role))
.collect(Collectors.toSet());
}
@SuppressWarnings("unchecked")
private Stream<String> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm\_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Stream.empty();
}
return ((Collection<String>) realmAccess.get("roles")).stream();
}
@SuppressWarnings("unchecked")
private Stream<String> extractClientRoles(Jwt jwt, String clientId) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource\_access");
if (resourceAccess == null || !resourceAccess.containsKey(clientId)) {
return Stream.empty();
}
Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(clientId);
if (clientAccess == null || !clientAccess.containsKey("roles")) {
return Stream.empty();
}
return ((Collection<String>) clientAccess.get("roles")).stream();
}
}
Plug it into your security configuration:
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.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
// Pass your specific client ID here
jwtConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRoleConverter("my-client"));
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))
);
http.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/admin").hasRole("admin")
.anyRequest().authenticated()
);
return http.build();
}
}
Returning clean errors: RFC 7807
When a 403 or 401 does happen, Spring Security's default behavior in a REST API isn't great — sometimes an empty body, sometimes an HTML error page. Modern APIs should return structured errors, and RFC 7807 (Problem Details for HTTP APIs) is the standard for that.
You can implement custom entry points to return properly formatted ProblemDetail objects:
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
public class ProblemDetailAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/problem+json");
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Access Denied");
problem.setInstance(URI.create(request.getRequestURI()));
objectMapper.writeValue(response.getOutputStream(), problem);
}
}
Plug this into your HttpSecurity configuration under .exceptionHandling(), and your clients get clean, parsable JSON errors instead.
Verification
To make sure your mapping actually works, write an integration test with Testcontainers: spin up a real Keycloak instance, import a realm with your test users and roles, and issue a real request.
@Test
void shouldAllowAdminAccess() {
String token = keycloak.getAccessToken("admin-user", "password");
mockMvc.perform(get("/api/admin")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
Getting this out of your codebase
If you don't want to maintain this boilerplate in every microservice, I packaged this exact configuration into a Spring Boot starter: spring-keycloak-toolkit. It handles the realm and resource role mapping automatically and enforces RFC 7807 error responses for security exceptions.
It's not on Maven Central yet, but it's on JitPack, which builds straight from the GitHub tag:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.jihedbfr-art</groupId>
<artifactId>spring-keycloak-toolkit</artifactId>
<version>v0.1.0</version>
</dependency>
Or clone it and run mvn install locally if you'd rather not pull from JitPack. Either way, set the client ID in your properties and the role mapping just works:
jihedapps:
keycloak-toolkit:
resource-id: my-client
Check the repo's README for the current release status if you're reading this later — a Central release may already be out by then.
---
Code and issues: github.com/jihedbfr-art/spring-keycloak-toolkit
Top comments (0)