← All posts

Keycloak and Spring Security: Stop Writing Auth Filters

2 May 20269 min read
KeycloakOAuth 2.0Spring SecurityAuth

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

application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # everything else is discovered from here
          issuer-uri: https://auth.example.com/realms/connect

app:
  audience: connect-api

That 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.

Container gotchaIn Docker, the issuer in the token (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.

Keycloak access token — the relevant part
{
  "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.

KeycloakAuthoritiesConverter.java
/**
 * 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();
  }
}
SecurityConfig.java
@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.

application.yml
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
Outbound client
@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();
}
Do not forward the user's tokenPassing a user's access token down the call chain hands every downstream service the user's full privileges, and any one of them can then act as that user against a fourth. If a downstream service genuinely needs user context, pass the subject explicitly or use token exchange — deliberately, with a narrowed audience.

Testing without a running Keycloak

Spinning up Keycloak for unit tests is slow and flaky. spring-security-test can fabricate an authenticated JWT directly.

OrderControllerTest.java
@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.

The short versionSet 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.