DEV Community

Jihed Ben Arfa
Jihed Ben Arfa

Posted on

Your Keycloak roles aren't working in Spring Security. Here's the actual reason.

If you've wired up a Spring Boot resource server behind Keycloak and your @PreAuthorize("hasRole('ADMIN')") is silently returning 403 for a user you can see has the ADMIN role in the admin console, you're not going crazy. This happens on basically every first Keycloak + Spring Security integration, and the reason is boring once you see it: Spring Security's default JWT converter has no idea Keycloak exists.

I've hit this on every Keycloak-secured backend I've built over the past couple of years — most recently on a multi-service Spring Boot 3 platform where I ended up owning the IAM layer, including custom Keycloak SPI authenticators. Same wiring problem, every time, until I finally pulled the fix out into a small library instead of copy-pasting it again.

The actual token shape

Spring Security's JwtAuthenticationConverter expects authorities to come from a scope (or scp) claim — a flat, space-separated string, OAuth2-style. That's the generic spec-y default. Keycloak doesn't do that. A Keycloak access token puts roles here instead:

{
  "realm_access": {
    "roles": ["offline_access", "uma_authorization"]
  },
  "resource_access": {
    "my-client": {
      "roles": ["ADMIN", "VIEWER"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Realm roles under realm_access.roles, client roles nested under resource_access.<clientId>.roles. Spring Security never looks there. So JwtAuthenticationConverter runs, finds no scope claim, produces zero GrantedAuthority objects, and every hasRole(...) check quietly fails — no error, no stack trace, just "access denied" for a user who is very clearly in the right group.

The fix, the manual version

You write your own Converter<Jwt, AbstractAuthenticationToken> that reads both claims, flattens them into SimpleGrantedAuthority with a ROLE_ prefix (Spring Security's convention), and registers it on the JwtAuthenticationConverter:

public class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        List<String> roles = realmAccess != null
                ? (List<String>) realmAccess.getOrDefault("roles", List.of())
                : List.of();

        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
    }
}
Enter fullscreen mode Exit fullscreen mode

Wire it into a JwtAuthenticationConverter, register that as a bean, and roles start working. That's maybe twenty lines and it's fine for a single service. The annoying part is you'll write this exact converter again on the next service, and the one after that, usually slightly differently each time (some people prefix with ROLE_, some don't, some forget client roles exist at all and only handle realm roles).

Where I landed

After the third time writing basically the same class, I pulled it into spring-keycloak-toolkit — an auto-configuration that registers the converter for you, handles both realm and client roles, and also fixes the second thing that bites you right after the first one: Spring Security's default 401/403 responses are empty bodies, which is useless if anything downstream (a frontend, an API gateway, a support engineer reading logs) needs to know why a request got rejected. The library adds RFC 7807 problem+json bodies for both cases instead.

What it deliberately does not do is touch your SecurityFilterChain or guess which endpoints should be public. I thought about auto-wiring that too, and decided against it — guessing your endpoint matchers wrong and failing silently is worse than making you write four lines of config yourself. A library that gets your security posture subtly wrong is more dangerous than one that does less.

If you're wiring up Keycloak with Spring Security and hitting this, the manual converter above will get you unstuck in five minutes. If you're doing it more than once, the library's on Maven via JitPack — link's in the repo.

Top comments (1)

Collapse
 
circuit profile image
Rahul S

The converter fix is right, but flattening realm_access and resource_access into one authority set quietly loses a distinction that bites later. A realm role "admin" and a resource_access.someclient.roles "admin" mean different things — realm-wide vs scoped to one client — and once both become ROLE_ADMIN, a token minted for a different client in the same realm that happens to carry an admin client-role satisfies hasRole('ADMIN') here too. If what you meant was "admin of this resource server," you just widened it. I'd keep client roles namespaced (pull only resource_access[myClientId].roles, or prefix with the clientId) so a role issued for another audience can't authorize against yours.

Which is really the same gap one level down: hasRole is only as trustworthy as your audience check. Keycloak access tokens are bearer and share an issuer across the realm, so if the resource server doesn't validate aud/azp it'll accept a valid-signature token that was never meant for it — the roles map, the check passes, nothing looks wrong. Worth pairing the converter with an audience validator, otherwise you've fixed "roles don't work" and left "roles work for tokens that weren't issued to me."