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
- Keycloak 24+ (or Keycloak 26.x for latest features)
- Spring Boot 3.2+ with Spring Security 6.x
- Java 17+
- A running Keycloak instance (see our Keycloak Docker Compose Production Guide for setup)
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:
- Create a realm (e.g.,
myrealm) - 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/*
- Client type:
- Add realm roles:
ROLE_USER,ROLE_ADMIN - 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 developmentSSO 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-urimust usehttps:// - 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.
Related Resources
- Keycloak Complete Guide — Full Keycloak feature overview
- Keycloak Docker Compose Production — Production deployment setup
- Keycloak High Availability — Clustering configuration
- OAuth 2.0 Authorization Code Flow with PKCE — Secure browser-based OAuth
- JWT Decode Tool — Debug your Keycloak tokens
- PKCE Generator — Generate PKCE code challenges online
For ForgeRock/PingOne AIC integrations with Java, see the ForgeRock Deep Dive guide.
