DEV Community

Jihed Ben Arfa
Jihed Ben Arfa

Posted on

Why Keycloak Roles Don't Reach Spring Security

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";
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
<dependency>
    <groupId>com.github.jihedbfr-art</groupId>
    <artifactId>spring-keycloak-toolkit</artifactId>
    <version>v0.1.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)