Integrating Keycloak with Spring Boot for OAuth2 resource server protection is one of the most searched tasks in the IAM developer community — yet most tutorials stop at “hello world” level. This guide covers production-grade integration: JWT validation, Keycloak realm role extraction, multi-tenant setups, and integration testing strategies.

Clone the companion repo: All working code in this guide is available at github.com/IAMDevBox/keycloak-spring-boot-oauth2 — includes Docker Compose for Keycloak, complete Spring Boot 3.x application, and integration tests with Testcontainers.

Prerequisites

Project Setup

Maven Dependencies

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- For integration testing -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>keycloak</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The spring-boot-starter-oauth2-resource-server dependency is the key addition. It includes Spring Security’s JWT support and auto-configures JWKS endpoint fetching from your issuer.

Keycloak Realm Configuration

Before configuring Spring Boot, set up your Keycloak realm:

  1. Create a realm (e.g., myrealm)
  2. Create a client (e.g., my-spring-app) with:
    • Client type: OpenID Connect
    • Client authentication: On (for confidential clients)
    • Valid redirect URIs: http://localhost:8081/*
  3. Add realm roles: ROLE_USER, ROLE_ADMIN
  4. Assign roles to test users

Basic Spring Boot Configuration

application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Must match Keycloak's realm URL exactly
          issuer-uri: http://localhost:8080/realms/myrealm

server:
  port: 8081

Spring Boot auto-discovers the JWKS endpoint from {issuer-uri}/.well-known/openid-configuration. This means no manual key configuration — Spring fetches and caches Keycloak’s public keys automatically.

Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").hasRole("USER")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(keycloakJwtConverter())
                )
            );

        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter keycloakJwtConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        // Do NOT set default authority prefix here — we handle it in custom converter
        converter.setAuthoritiesClaimName("scope");

        KeycloakRoleConverter keycloakConverter = new KeycloakRoleConverter();

        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(keycloakConverter);
        return jwtConverter;
    }
}

Extracting Keycloak Realm Roles

This is the most common pain point. Keycloak places realm roles inside realm_access.roles — a nested JSON structure that Spring Security doesn’t understand out of the box.

Keycloak JWT Structure

A Keycloak-issued JWT contains:

{
  "realm_access": {
    "roles": ["ROLE_USER", "ROLE_ADMIN", "offline_access"]
  },
  "resource_access": {
    "my-spring-app": {
      "roles": ["view-profile"]
    }
  },
  "scope": "openid profile email",
  "preferred_username": "john.doe"
}

Custom Role Converter

@Component
public class KeycloakRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        List<GrantedAuthority> authorities = new ArrayList<>();

        // Extract realm-level roles
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null && realmAccess.containsKey("roles")) {
            List<String> realmRoles = (List<String>) realmAccess.get("roles");
            realmRoles.stream()
                .filter(role -> !role.equals("offline_access") && !role.equals("uma_authorization"))
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                .forEach(authorities::add);
        }

        // Optionally extract client-level roles
        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
        if (resourceAccess != null) {
            resourceAccess.values().stream()
                .filter(v -> v instanceof Map)
                .flatMap(v -> {
                    Map<String, Object> clientRoles = (Map<String, Object>) v;
                    List<String> roles = (List<String>) clientRoles.getOrDefault("roles", List.of());
                    return roles.stream();
                })
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                .forEach(authorities::add);
        }

        return authorities;
    }
}

Example REST Controller

@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/profile")
    @PreAuthorize("hasRole('USER')")
    public Map<String, Object> getProfile(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "username", jwt.getClaim("preferred_username"),
            "email", jwt.getClaim("email"),
            "roles", jwt.getClaim("realm_access")
        );
    }

    @GetMapping("/admin/users")
    @PreAuthorize("hasRole('ADMIN')")
    public String adminEndpoint() {
        return "Admin access granted";
    }

    @GetMapping("/token-info")
    public Map<String, Object> tokenInfo(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "subject", jwt.getSubject(),
            "issuer", jwt.getIssuer().toString(),
            "expires", jwt.getExpiresAt().toString(),
            "claims", jwt.getClaims()
        );
    }
}

The @AuthenticationPrincipal Jwt injection gives you direct access to all token claims without additional boilerplate.

Token Audience Validation

Production deployments should validate the token audience to prevent token reuse across services:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://keycloak:8080/realms/myrealm
@Bean
public JwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties) {
    NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(
        properties.getJwt().getIssuerUri()
    );

    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
        JwtClaimNames.AUD,
        aud -> aud != null && aud.contains("my-spring-app")
    );

    OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(
        JwtValidators.createDefaultWithIssuer(properties.getJwt().getIssuerUri()),
        audienceValidator
    );

    decoder.setJwtValidator(withAudience);
    return decoder;
}

To configure the audience in Keycloak, go to Client → Settings → Audience and add the client ID as an audience in the access token.

Multi-Tenant Keycloak Integration

If your Spring Boot application serves multiple tenants, each with their own Keycloak realm:

@Component
public class MultiTenantJwtDecoder implements JwtDecoder {

    private final Map<String, JwtDecoder> decoders = new ConcurrentHashMap<>();
    private final String keycloakBaseUrl;

    public MultiTenantJwtDecoder(@Value("${keycloak.base-url}") String keycloakBaseUrl) {
        this.keycloakBaseUrl = keycloakBaseUrl;
    }

    @Override
    public Jwt decode(String token) throws JwtException {
        // Extract realm from token claims before full validation
        String realm = extractRealm(token);
        JwtDecoder decoder = decoders.computeIfAbsent(realm, this::createDecoder);
        return decoder.decode(token);
    }

    private String extractRealm(String token) {
        // Parse issuer claim from token without validation
        try {
            JWT jwt = JWTParser.parse(token);
            String issuer = (String) jwt.getJWTClaimsSet().getClaim("iss");
            // Expected format: http://keycloak:8080/realms/{realm}
            return issuer.substring(issuer.lastIndexOf("/") + 1);
        } catch (Exception e) {
            throw new JwtException("Cannot extract realm from token");
        }
    }

    private JwtDecoder createDecoder(String realm) {
        String issuerUri = keycloakBaseUrl + "/realms/" + realm;
        return JwtDecoders.fromIssuerLocation(issuerUri);
    }
}

This pattern caches decoders per realm and lazily initializes them on first request.

Integration Testing with Testcontainers

Testing with a real Keycloak instance is the most reliable approach:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class KeycloakIntegrationTest {

    @Container
    static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.0")
        .withRealmImportFile("test-realm.json");

    @DynamicPropertySource
    static void keycloakProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
            () -> keycloak.getAuthServerUrl() + "/realms/test");
    }

    @Test
    void testProtectedEndpoint() throws Exception {
        String token = obtainToken("testuser", "password");

        mockMvc.perform(get("/api/profile")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }

    private String obtainToken(String username, String password) {
        // Use Keycloak REST API to obtain token
        return given()
            .contentType("application/x-www-form-urlencoded")
            .formParam("grant_type", "password")
            .formParam("client_id", "test-client")
            .formParam("client_secret", "test-secret")
            .formParam("username", username)
            .formParam("password", password)
            .post(keycloak.getAuthServerUrl() + "/realms/test/protocol/openid-connect/token")
            .jsonPath().getString("access_token");
    }
}

For unit tests where you don’t need a real Keycloak:

@Test
void testWithMockedJwt() throws Exception {
    mockMvc.perform(get("/api/profile")
            .with(jwt()
                .jwt(jwt -> jwt
                    .claim("preferred_username", "testuser")
                    .claim("realm_access", Map.of("roles", List.of("USER")))
                )
            ))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.username").value("testuser"));
}

Common Issues and Fixes

401 Unauthorized: Invalid Issuer

Symptom: Invalid issuer. Expected http://localhost:8080/realms/myrealm

Cause: The issuer-uri in your application.yml must exactly match the iss claim in the Keycloak JWT, including the protocol (http vs https) and exact realm name.

Fix: Decode your token at iamdevbox.com/tools/jwt-decode/ and compare the iss claim with your configured issuer-uri.

403 Forbidden: Missing Roles

Symptom: Authentication succeeds but all role-protected endpoints return 403.

Cause: Without a custom JwtGrantedAuthoritiesConverter, Spring Security only reads the scope claim — not Keycloak’s realm_access.roles.

Fix: Implement the KeycloakRoleConverter shown above. Verify role names — Spring Security’s hasRole("USER") checks for ROLE_USER authority.

CORS Errors on Token Validation

Symptom: Browser requests fail with CORS error when fetching JWKS.

Cause: CORS is a browser concern, not a Spring Boot one. Your backend fetches JWKS server-side (no CORS issue). If you see CORS errors, they’re from the browser calling Keycloak directly.

Fix: For browser-based OAuth flows, use the Authorization Code Flow with PKCE — the browser handles the redirect, and your backend calls Keycloak server-to-server.

Token Expired Too Quickly

Symptom: Tokens expire in 5 minutes, causing constant re-authentication.

Fix: In Keycloak Admin Console → Realm Settings → Tokens:

  • Access Token Lifespan: 15-30 minutes for development
  • SSO Session Idle: 30 minutes
  • Implement refresh token rotation in your client

Production Security Checklist

Before deploying to production:

  • Use HTTPS for both Keycloak and Spring Boot — issuer-uri must use https://
  • Enable audience validation (prevents token reuse across services)
  • Configure PKCE for all browser clients (see PKCE Generator)
  • Rotate Keycloak realm keys annually (Admin → Realm Settings → Keys)
  • Set appropriate token lifetimes (access: 5-15 min, refresh: 8h-30d)
  • Enable Keycloak event logging for audit trails
  • Use Keycloak Health endpoints in your Kubernetes liveness probes
  • Test token revocation — ensure your app respects token expiry

Advanced: Custom Claims Mapper

Add custom data to tokens via Keycloak protocol mappers:

// Reading a custom claim from Keycloak token
@GetMapping("/user-data")
public Map<String, Object> getUserData(@AuthenticationPrincipal Jwt jwt) {
    // Custom claim set via Keycloak Protocol Mapper → User Attribute mapper
    String department = jwt.getClaim("department");
    String employeeId = jwt.getClaim("employee_id");

    return Map.of(
        "department", department != null ? department : "unknown",
        "employeeId", employeeId != null ? employeeId : "unknown"
    );
}

Configure in Keycloak: Client → Client Scopes → Add Mapper → User Attribute. Map your user attribute to the token claim name.

For ForgeRock/PingOne AIC integrations with Java, see the ForgeRock Deep Dive guide.