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.
답글 남기기