Spring Boot Integration
This guide covers how to integrate Skycloak authentication and authorization into your Spring Boot applications using Spring Security OAuth2.
Important: Keycloak Spring Boot Adapter is Deprecated
The keycloak-spring-boot-starter adapter has been officially deprecated by the Keycloak team and is no longer maintained. It does not work with Spring Boot 3.x.
Recommended approach: Use Spring Security OAuth2 (shown below) for all new projects and for upgrading existing applications.
Prerequisites
- Spring Boot 3.x application (recommended)
- Java 17 or higher (Java 21 recommended for new projects)
- Skycloak cluster with configured realm and client
- Basic understanding of Spring Security
Quick Start
1. Add Dependencies
Add Spring Security OAuth2 dependencies to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>Or for Gradle (build.gradle.kts):
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")2. Configure Application Properties
Add the following to your application.yml:
spring:
security:
oauth2:
client:
registration:
skycloak:
client-id: your-client-id
client-secret: your-client-secret
authorization-grant-type: authorization_code
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
scope: openid, profile, email
provider:
skycloak:
issuer-uri: https://your-cluster-id.app.skycloak.io/realms/your-realm
resourceserver:
jwt:
issuer-uri: https://your-cluster-id.app.skycloak.io/realms/your-realm3. Security Configuration
Create your security configuration class:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}Controller Examples
Protected REST API
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/user/profile")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<UserProfile> getUserProfile(Authentication authentication) {
Jwt jwt = (Jwt) authentication.getPrincipal();
UserProfile profile = new UserProfile();
profile.setId(jwt.getSubject());
profile.setUsername(jwt.getClaimAsString("preferred_username"));
profile.setEmail(jwt.getClaimAsString("email"));
profile.setRoles(extractRoles(jwt));
return ResponseEntity.ok(profile);
}
@PostMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
// Admin-only endpoint
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
private List<String> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
return (List<String>) realmAccess.get("roles");
}
return Collections.emptyList();
}
}Method-Level Security
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public UserData getUserData(String username) {
// Users can access their own data, admins can access any
return userRepository.findByUsername(username);
}
@PostAuthorize("returnObject.owner == authentication.name")
public Document getDocument(Long documentId) {
// Ensures users can only retrieve their own documents
return documentRepository.findById(documentId);
}
@PreAuthorize("@securityService.hasPermission(#resourceId, 'WRITE')")
public void updateResource(Long resourceId, ResourceData data) {
// Custom permission checking
resourceRepository.update(resourceId, data);
}
}Advanced Configuration
Custom JWT Claims Converter
@Component
public class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
Map<String, Object> claims = jwt.getClaims();
// Extract custom claims
String tenant = jwt.getClaimAsString("tenant");
List<String> groups = jwt.getClaimAsStringList("groups");
// Create custom authentication token
CustomAuthenticationToken token = new CustomAuthenticationToken(
jwt.getSubject(),
authorities,
tenant,
groups
);
return token;
}
private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>();
// Extract realm roles
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null) {
List<String> roles = (List<String>) realmAccess.get("roles");
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
// Extract resource roles
Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
if (resourceAccess != null) {
resourceAccess.forEach((resource, access) -> {
Map<String, Object> resourceRoles = (Map<String, Object>) access;
List<String> roles = (List<String>) resourceRoles.get("roles");
roles.forEach(role -> authorities.add(
new SimpleGrantedAuthority(resource + "_" + role)
));
});
}
return authorities;
}
}Multi-Tenancy Support
@Configuration
public class MultiTenantSecurityConfig {
@Bean
public JwtIssuerAuthenticationManagerResolver authenticationManagerResolver() {
Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();
return new JwtIssuerAuthenticationManagerResolver(
issuer -> authenticationManagers.computeIfAbsent(issuer, this::createAuthenticationManager)
);
}
private AuthenticationManager createAuthenticationManager(String issuer) {
JwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuer);
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(jwtDecoder);
provider.setJwtAuthenticationConverter(jwtAuthenticationConverter());
return provider::authenticate;
}
}Service-to-Service Authentication
@Configuration
public class ServiceAuthenticationConfig {
@Value("${keycloak.client-id}")
private String clientId;
@Value("${keycloak.client-secret}")
private String clientSecret;
@Value("${keycloak.token-uri}")
private String tokenUri;
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
@Bean
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Client.setDefaultClientRegistrationId("skycloak");
return WebClient.builder()
.filter(oauth2Client)
.build();
}
}Using the WebClient for authenticated requests:
@Service
public class ExternalApiService {
private final WebClient webClient;
public ExternalApiService(WebClient webClient) {
this.webClient = webClient;
}
public Mono<ApiResponse> callProtectedApi() {
return webClient
.get()
.uri("https://api.example.com/protected")
.retrieve()
.bodyToMono(ApiResponse.class);
}
}Testing
Integration Tests
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.security.oauth2.resourceserver.jwt.issuer-uri=https://test.skycloak.io/realms/test"
})
class ApiControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockJwtAuth(authorities = "ROLE_USER", claims = @OpenIdClaims(sub = "user123"))
void getUserProfile_WithValidToken_ReturnsProfile() throws Exception {
mockMvc.perform(get("/api/user/profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("user123"));
}
@Test
void getProtectedEndpoint_WithoutToken_ReturnsUnauthorized() throws Exception {
mockMvc.perform(get("/api/user/profile"))
.andExpect(status().isUnauthorized());
}
}Mock JWT Authentication
@TestConfiguration
public class SecurityTestConfig {
@Bean
public JwtDecoder jwtDecoder() {
return mock(JwtDecoder.class);
}
@Bean
public JwtAuthenticationToken mockJwtAuthentication() {
Jwt jwt = Jwt.withTokenValue("mock-token")
.header("alg", "RS256")
.claim("sub", "test-user")
.claim("preferred_username", "testuser")
.claim("realm_access", Map.of("roles", List.of("USER", "ADMIN")))
.build();
return new JwtAuthenticationToken(jwt);
}
}Production Best Practices
1. Token Validation Caching
@Configuration
public class JwtCacheConfig {
@Bean
public JwtDecoder cachedJwtDecoder() {
NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);
// Configure caching
jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
new JwtTimestampValidator(Duration.ofMinutes(5))
));
// Add cache
return new CachingJwtDecoder(jwtDecoder);
}
}2. Circuit Breaker for Token Validation
@Component
public class ResilientJwtDecoder implements JwtDecoder {
private final JwtDecoder delegate;
private final CircuitBreaker circuitBreaker;
public ResilientJwtDecoder(JwtDecoder delegate) {
this.delegate = delegate;
this.circuitBreaker = CircuitBreaker.ofDefaults("jwt-decoder");
circuitBreaker.getEventPublisher()
.onError(event -> log.error("JWT decoding error: {}", event.getThrowable().getMessage()));
}
@Override
public Jwt decode(String token) throws JwtException {
return circuitBreaker.executeSupplier(() -> delegate.decode(token));
}
}3. Security Headers
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.frameOptions().deny()
.xssProtection().and()
.contentSecurityPolicy("default-src 'self'")
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}4. Audit Logging
@Component
@Slf4j
public class SecurityAuditListener {
@EventListener
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
log.info("User {} successfully authenticated", auth.getName());
auditService.logAuthenticationSuccess(auth);
}
@EventListener
public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
log.warn("Authentication failed: {}", event.getException().getMessage());
auditService.logAuthenticationFailure(event.getException());
}
@EventListener
public void handleAuthorizationFailure(AuthorizationDeniedEvent event) {
log.warn("Authorization denied for user {}: {}",
event.getAuthentication().getName(),
event.getAccessDeniedException().getMessage());
auditService.logAuthorizationFailure(event);
}
}Troubleshooting
Common Issues
-
401 Unauthorized Errors
- Verify token issuer URI matches exactly
- Check token expiration and clock skew
- Ensure client credentials are correct
-
Role Mapping Issues
- Verify role claim path (realm_access.roles vs resource_access)
- Check role prefix configuration (ROLE_ prefix)
- Ensure roles are assigned in Skycloak
-
CORS Problems
- Configure allowed origins properly
- Include Authorization in allowed headers
- Handle preflight requests
Debug Configuration
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: TRACE
org.keycloak: DEBUG