Spring Boot + Keycloak OAuth 2.0: The Complete 2026 Guide
Last updated: March 2026
Introduction
The Keycloak Spring Boot adapter was deprecated in Keycloak 20 and removed entirely in later versions. If you are following guides that reference keycloak-spring-boot-starter or KeycloakWebSecurityConfigurerAdapter, those approaches no longer work with current versions of either Keycloak or Spring Boot.
The modern approach uses Spring Security’s built-in OAuth 2.0 Resource Server support (spring-security-oauth2-resource-server). This is simpler, more maintainable, and follows Spring Security’s current architecture. No Keycloak-specific dependencies are needed.
This guide covers Spring Boot 3.x with Spring Security 6.x and Keycloak 26.x. We build a REST API with JWT-based authentication, custom role mapping from Keycloak’s token structure, method-level security annotations, and WebClient with token propagation.
For migrating from the legacy adapter, see our migration guide. For a complete token validation reference, see Keycloak token validation for APIs.
Project Setup
Dependencies (Maven)
<!-- pom.xml -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.1</version>
</parent>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth 2.0 Resource Server (JWT validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- WebClient for token propagation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Dependencies (Gradle)
// build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
Application Configuration
# src/main/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/spring-demo
# Alternatively, specify the JWKS endpoint directly:
# jwk-set-uri: http://localhost:8080/realms/spring-demo/protocol/openid-connect/certs
# Custom properties for Keycloak client (if needed for token propagation)
keycloak:
auth-server-url: http://localhost:8080
realm: spring-demo
client-id: spring-api
client-secret: ${KEYCLOAK_CLIENT_SECRET:}
server:
port: 8081
logging:
level:
org.springframework.security: DEBUG
Spring Security automatically:
- Fetches Keycloak’s OIDC discovery document from the issuer URI.
- Downloads the JWKS (public keys) for signature verification.
- Validates incoming JWT tokens (signature, expiry, issuer).
Security Configuration
Basic SecurityFilterChain
// src/main/java/com/example/config/SecurityConfig.java
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration
.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders
.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration
.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication
.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Enables @PreAuthorize, @Secured, etc.
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers(
"/api/public/**",
"/actuator/health"
).permitAll()
// Admin endpoints
.requestMatchers("/api/admin/**")
.hasRole("ADMIN")
// Everything else requires authentication
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(
jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter =
new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
new KeycloakRoleConverter());
return converter;
}
}
Keycloak Role Converter
Keycloak stores roles in a nested structure (realm_access.roles and resource_access.<client>.roles). Spring Security expects a flat collection of GrantedAuthority objects. This converter bridges the gap:
// src/main/java/com/example/config/KeycloakRoleConverter.java
package com.example.config;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class KeycloakRoleConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
// Extract realm roles
Stream<String> realmRoles = extractRealmRoles(jwt);
// Extract client roles (all clients)
Stream<String> clientRoles = extractClientRoles(jwt);
// Combine and prefix with ROLE_ for Spring Security
return Stream.concat(realmRoles, clientRoles)
.map(role -> new SimpleGrantedAuthority(
"ROLE_" + role.toUpperCase()))
.collect(Collectors.toSet());
}
private Stream<String> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess =
jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return Stream.empty();
}
@SuppressWarnings("unchecked")
List<String> roles =
(List<String>) realmAccess.get("roles");
return roles != null ? roles.stream() : Stream.empty();
}
private Stream<String> extractClientRoles(Jwt jwt) {
Map<String, Object> resourceAccess =
jwt.getClaimAsMap("resource_access");
if (resourceAccess == null) {
return Stream.empty();
}
return resourceAccess.values().stream()
.filter(v -> v instanceof Map)
.flatMap(clientAccess -> {
@SuppressWarnings("unchecked")
Map<String, Object> access =
(Map<String, Object>) clientAccess;
@SuppressWarnings("unchecked")
List<String> roles =
(List<String>) access.get("roles");
return roles != null
? roles.stream()
: Stream.empty();
});
}
}
This converter maps Keycloak’s realm_access.roles: ["admin", "user"] to Spring Security authorities ROLE_ADMIN and ROLE_USER. You can verify your token’s role structure with the JWT Token Analyzer.
REST Controllers
Public and Protected Endpoints
// src/main/java/com/example/controller/ApiController.java
package com.example.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class ApiController {
// Public - no authentication required
@GetMapping("/public/health")
public Map<String, String> health() {
return Map.of("status", "UP");
}
// Requires any authenticated user
@GetMapping("/me")
public Map<String, Object> getCurrentUser(
@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"sub", jwt.getSubject(),
"email", jwt.getClaimAsString("email"),
"username",
jwt.getClaimAsString("preferred_username"),
"roles", extractRoles(jwt)
);
}
// Requires specific role via URL-level security
@GetMapping("/admin/users")
public List<Map<String, String>> listUsers() {
// Admin-only endpoint (secured in SecurityConfig)
return List.of(
Map.of("id", "1", "name", "Jane Developer"),
Map.of("id", "2", "name", "John Admin")
);
}
// Method-level security with @PreAuthorize
@PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
@PostMapping("/projects")
public Map<String, Object> createProject(
@RequestBody Map<String, String> project,
@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"id", "proj-" + System.currentTimeMillis(),
"name", project.get("name"),
"createdBy", jwt.getSubject()
);
}
// Check custom claims
@PreAuthorize(
"@authz.isOrgMember(#jwt, #orgId)")
@GetMapping("/orgs/{orgId}/data")
public Map<String, String> getOrgData(
@PathVariable String orgId,
@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"orgId", orgId,
"data", "sensitive organization data"
);
}
private List<String> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess =
jwt.getClaimAsMap("realm_access");
if (realmAccess == null) return List.of();
@SuppressWarnings("unchecked")
List<String> roles =
(List<String>) realmAccess.get("roles");
return roles != null ? roles : List.of();
}
}
Custom Authorization Bean
For complex authorization logic, define a Spring bean referenced in @PreAuthorize:
// src/main/java/com/example/security/AuthorizationService.java
package com.example.security;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component("authz")
public class AuthorizationService {
/**
* Check if the JWT user is a member of the specified
* organization.
* Works with Keycloak Organizations claims.
*/
public boolean isOrgMember(Jwt jwt, String orgId) {
Map<String, Object> orgs =
jwt.getClaimAsMap("organizations");
return orgs != null && orgs.containsKey(orgId);
}
/**
* Check if the user has a specific role within
* an organization.
*/
public boolean hasOrgRole(
Jwt jwt, String orgId, String role) {
Map<String, Object> orgs =
jwt.getClaimAsMap("organizations");
if (orgs == null || !orgs.containsKey(orgId)) {
return false;
}
@SuppressWarnings("unchecked")
Map<String, Object> orgData =
(Map<String, Object>) orgs.get(orgId);
@SuppressWarnings("unchecked")
java.util.List<String> roles =
(java.util.List<String>) orgData.get("roles");
return roles != null && roles.contains(role);
}
/**
* Check if the user owns a specific resource.
*/
public boolean isResourceOwner(
Jwt jwt, String resourceOwnerId) {
return jwt.getSubject().equals(resourceOwnerId);
}
}
Use in controllers:
@PreAuthorize("@authz.hasOrgRole(#jwt, #orgId, 'admin')")
@DeleteMapping("/orgs/{orgId}")
public void deleteOrg(
@PathVariable String orgId,
@AuthenticationPrincipal Jwt jwt) {
// Only org admins can delete
}
WebClient with Token Propagation
When your service needs to call other protected APIs, propagate the incoming JWT:
// src/main/java/com/example/config/WebClientConfig.java
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client
.registration.ClientRegistrationRepository;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class WebClientConfig {
/**
* WebClient that automatically includes the current
* user's JWT in outgoing requests.
*/
@Bean
public WebClient webClient() {
return WebClient.builder()
.baseUrl("http://localhost:8082")
.build();
}
}
// src/main/java/com/example/service/DownstreamService.java
package com.example.service;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource
.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
@Service
public class DownstreamService {
private final WebClient webClient;
public DownstreamService(WebClient webClient) {
this.webClient = webClient;
}
public String fetchUserProjects() {
// Get the current JWT from the security context
JwtAuthenticationToken auth =
(JwtAuthenticationToken) SecurityContextHolder
.getContext().getAuthentication();
Jwt jwt = auth.getToken();
// Propagate the token to the downstream service
return webClient.get()
.uri("/api/projects")
.headers(headers ->
headers.setBearerAuth(jwt.getTokenValue()))
.retrieve()
.bodyToMono(String.class)
.block();
}
}
Client Credentials for Service-to-Service
For background jobs or service-to-service calls that are not tied to a user, use client credentials:
# application.yml - add OAuth2 client registration
spring:
security:
oauth2:
client:
registration:
keycloak-service:
provider: keycloak
client-id: spring-api
client-secret: ${KEYCLOAK_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: openid
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/spring-demo
// Service-to-service WebClient with client credentials
@Bean
public WebClient serviceWebClient(
OAuth2AuthorizedClientManager clientManager) {
var oauth2 =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(
clientManager);
oauth2.setDefaultClientRegistrationId("keycloak-service");
return WebClient.builder()
.apply(oauth2.oauth2Configuration())
.baseUrl("http://localhost:8082")
.build();
}
For more on machine-to-machine patterns, see our M2M authentication guide.
Keycloak Configuration
Create the Realm and Client
- Create realm
spring-demo. - Create client
spring-api:
| Setting | Value |
|---|---|
| Client ID | spring-api |
| Client Authentication | ON |
| Service Accounts Enabled | ON |
| Valid Redirect URIs | http://localhost:8081/* |
- Create realm roles:
admin,user,manager. - Create a test user, assign roles.
For a guided setup, use the Keycloak Config Generator.
Audience Mapper
If your API validates the aud claim, add an audience mapper:
- Go to client
spring-api> Client Scopes > Dedicated scope. - Add mapper: Audience > Included Client Audience =
spring-api.
Then validate the audience in Spring:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/spring-demo
audiences: spring-api # Spring Boot 3.4+
Error Handling
Customize the error responses for authentication and authorization failures:
// src/main/java/com/example/config/SecurityExceptionHandler.java
package com.example.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web
.AuthenticationEntryPoint;
import org.springframework.security.web.access
.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
@Component
public class SecurityExceptionHandler
implements AuthenticationEntryPoint, AccessDeniedHandler {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException ex) throws IOException {
writeError(response, 401, "Unauthorized",
"A valid Keycloak access token is required");
}
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException ex) throws IOException {
writeError(response, 403, "Forbidden",
"Insufficient permissions for this resource");
}
private void writeError(
HttpServletResponse response,
int status, String error, String message)
throws IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(response.getOutputStream(), Map.of(
"timestamp", Instant.now().toString(),
"status", status,
"error", error,
"message", message
));
}
}
Register it in the security config:
// In SecurityConfig.filterChain()
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
.authenticationEntryPoint(securityExceptionHandler)
.accessDeniedHandler(securityExceptionHandler)
)
Testing
Unit Tests with Mock JWT
// src/test/java/com/example/controller/ApiControllerTest.java
package com.example.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web
.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.web.servlet.request
.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.springframework.test.web.servlet
.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet
.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class ApiControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void publicEndpointAccessibleWithoutAuth() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
@Test
void protectedEndpointRequiresAuth() throws Exception {
mockMvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized());
}
@Test
void authenticatedUserCanAccessProfile() throws Exception {
mockMvc.perform(get("/api/me")
.with(SecurityMockMvcRequestPostProcessors.jwt()
.jwt(jwt -> jwt
.subject("user-123")
.claim("email", "[email protected]")
.claim("preferred_username", "jane")
.claim("realm_access", Map.of(
"roles", List.of("user")
))
)
))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sub").value("user-123"))
.andExpect(jsonPath("$.email")
.value("[email protected]"));
}
@Test
void adminEndpointForbiddenForRegularUser()
throws Exception {
mockMvc.perform(get("/api/admin/users")
.with(SecurityMockMvcRequestPostProcessors.jwt()
.jwt(jwt -> jwt
.subject("user-123")
.claim("realm_access", Map.of(
"roles", List.of("user")
))
)
))
.andExpect(status().isForbidden());
}
@Test
void adminEndpointAccessibleForAdmin() throws Exception {
mockMvc.perform(get("/api/admin/users")
.with(SecurityMockMvcRequestPostProcessors.jwt()
.jwt(jwt -> jwt
.subject("admin-1")
.claim("realm_access", Map.of(
"roles", List.of("admin")
))
)
))
.andExpect(status().isOk());
}
}
For integration testing with a real Keycloak instance, see our guide on Keycloak Testcontainers.
Testing with curl
# Get token
TOKEN=$(curl -s -X POST
"http://localhost:8080/realms/spring-demo/protocol/openid-connect/token"
-d "grant_type=password"
-d "client_id=spring-api"
-d "client_secret=YOUR_SECRET"
-d "username=testuser"
-d "password=testpassword"
-d "scope=openid" | jq -r '.access_token')
# Public endpoint
curl -s http://localhost:8081/api/public/health | jq .
# Protected endpoint
curl -s -H "Authorization: Bearer ${TOKEN}"
http://localhost:8081/api/me | jq .
# Admin endpoint (requires admin role)
curl -s -H "Authorization: Bearer ${TOKEN}"
http://localhost:8081/api/admin/users | jq .
Production Checklist
- [ ] Use HTTPS between Spring Boot and Keycloak
- [ ] Set short token lifetimes (5-15 min access, 30 min refresh)
- [ ] Enable audit logging in Keycloak
- [ ] Configure CORS properly (see CORS guide)
- [ ] Use MFA for admin accounts
- [ ] Monitor with Skycloak Insights or Prometheus
- [ ] Validate audience claims in production
- [ ] Use environment variables for secrets (never hardcode)
Conclusion
Modern Spring Boot + Keycloak integration requires zero Keycloak-specific libraries. The spring-security-oauth2-resource-server dependency handles JWT validation, and a custom JwtAuthenticationConverter maps Keycloak’s role structure to Spring Security authorities.
Key takeaways:
- Use
spring-security-oauth2-resource-server, not the deprecated Keycloak adapter - Write a
KeycloakRoleConverterto maprealm_access.rolesto Spring authorities - Enable
@EnableMethodSecurityfor@PreAuthorizeannotations - Use custom authorization beans for complex business rules
- Spring’s
MockMvcwithSecurityMockMvcRequestPostProcessors.jwt()makes testing easy
For the official Spring Security documentation, see Spring Security OAuth 2.0 Resource Server. For Keycloak, consult the Keycloak Securing Applications Guide.
Ready to deploy your Spring Boot application with managed Keycloak? Try Skycloak free for production-ready identity management with enterprise hosting and SLA guarantees.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.