Keycloak and Spring Security: Stop Writing Auth Filters
The first Keycloak integration I inherited had a custom OncePerRequestFilter, a hand-rolled JWT parser, a hard-coded public key in application.yml, and a comment saying "TODO: handle key rotation". It worked. It was also about three hundred lines of liability standing in for roughly twelve lines of configuration.
The useful mental model: Keycloak is your authorisation server, your services are resource servers. Once you say that out loud, most of the custom code becomes obviously unnecessary.
Who does what
- Keycloak authenticates the human, handles MFA and password policy, and mints signed tokens. Your service never sees a password.
- Your Spring Boot service validates the signature against Keycloak's published keys, checks the claims, and maps them to authorities. Nothing more.
Your service does not talk to Keycloak on every request. It fetches the JWKS once, caches it, and verifies locally — which is the entire performance argument for JWTs.
The configuration
spring:
security:
oauth2:
resourceserver:
jwt:
# everything else is discovered from here
issuer-uri: https://auth.example.com/realms/connect
app:
audience: connect-apiThat is the integration. On startup Spring hits {issuer}/.well-known/openid-configuration, reads the jwks_uri, and caches the signing keys against their kid. When Keycloak rotates keys, tokens carry the new kid, Spring refetches, and nothing breaks. That is the TODO from the intro, already solved.
https://auth.example.com/...) must match issuer-uri exactly, even if your service reaches Keycloak at http://keycloak:8080 internally. Set Keycloak's KC_HOSTNAME to the public URL and let the internal hostname resolve to it. Mismatched issuers are the single most common cause of a mysterious 401 here.Keycloak roles are not Spring authorities
This trips up everyone once. Keycloak nests realm roles inside a claim Spring knows nothing about, so out of the box you get scopes but no roles, and hasRole('ADMIN') silently never matches.
{
"sub": "8c2b1f04-5a9d-4e77-b0c3-91f2ad6e4b18",
"aud": "connect-api",
"scope": "openid profile order:read",
"realm_access": {
"roles": ["PHARMACY_ADMIN", "offline_access"]
},
"resource_access": {
"connect-api": { "roles": ["ORDER_APPROVER"] }
}
}realm_access.roles applies across the realm; resource_access.{client}.roles is scoped to one client. Prefer the latter — realm roles leak across every service in the realm, which defeats the point of having separate services.
/**
* Flattens Keycloak's nested role claims into Spring authorities:
* scope -> SCOPE_order:read
* role -> ROLE_ORDER_APPROVER
*/
public class KeycloakAuthoritiesConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
private static final String CLIENT_ID = "connect-api";
private final JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>(scopes.convert(jwt));
rolesFrom(jwt.getClaimAsMap("realm_access"))
.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
Map<String, Object> resource = jwt.getClaimAsMap("resource_access");
if (resource != null && resource.get(CLIENT_ID) instanceof Map<?, ?> client) {
rolesFrom(client).forEach(r ->
authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
return authorities;
}
@SuppressWarnings("unchecked")
private static Collection<String> rolesFrom(Map<?, ?> claim) {
if (claim == null) return List.of();
Object roles = claim.get("roles");
return roles instanceof Collection ? (Collection<String>) roles : List.of();
}
}@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakAuthoritiesConverter());
return http
.csrf(CsrfConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/catalog/**").hasAuthority("SCOPE_catalog:read")
.requestMatchers("/api/v1/admin/**").hasRole("PHARMACY_ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(converter)))
.build();
}Roles or scopes?
They answer different questions, and conflating them produces authorisation rules nobody can reason about later.
- Scope — what the client application was permitted to ask for.
- Role — what the user is allowed to do.
A correct check is usually both: this client may write orders and this user is an approver. Scope alone means any token from that client passes. Role alone ignores what the user actually consented to.
Service-to-service calls
Internal calls have no user, so there is no user token to forward. Use the client credentials grant: the service authenticates as itself and gets a token with its own roles. Spring manages the lifecycle, including refresh.
spring:
security:
oauth2:
client:
registration:
inventory:
provider: keycloak
client-id: order-service
client-secret: ${ORDER_SERVICE_SECRET}
authorization-grant-type: client_credentials
scope: inventory:write
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/connect@Bean
WebClient inventoryClient(OAuth2AuthorizedClientManager manager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(manager);
oauth.setDefaultClientRegistrationId("inventory");
return WebClient.builder()
.baseUrl("http://inventory-service")
.apply(oauth.oauth2Configuration()) // acquires + caches + refreshes the token
.build();
}Testing without a running Keycloak
Spinning up Keycloak for unit tests is slow and flaky. spring-security-test can fabricate an authenticated JWT directly.
@Test
@DisplayName("approver can approve; plain user cannot")
void approvalRequiresRole() throws Exception {
mvc.perform(post("/api/v1/orders/42/approve")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ORDER_APPROVER"))))
.andExpect(status().isOk());
mvc.perform(post("/api/v1/orders/42/approve")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_VIEWER"))))
.andExpect(status().isForbidden());
}For integration tests where you want the real thing, Testcontainers has a Keycloak module that boots a realm from an exported JSON file. Worth it for the auth flows themselves; overkill for everything else.
issuer-uri, write one authorities converter because Keycloak nests its roles, check scope and role for different reasons, and use client credentials between services. Everything else Spring Security already does — and it handles key rotation, which your custom filter does not.