Spring Boot + Keycloak OAuth 2.0: The Complete 2026 Guide

Guilliano Molaire Guilliano Molaire Updated May 8, 2026 11 min read

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:

  1. Fetches Keycloak’s OIDC discovery document from the issuer URI.
  2. Downloads the JWKS (public keys) for signature verification.
  3. 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

  1. Create realm spring-demo.
  2. Create client spring-api:
Setting Value
Client ID spring-api
Client Authentication ON
Service Accounts Enabled ON
Valid Redirect URIs http://localhost:8081/*
  1. Create realm roles: admin, user, manager.
  2. 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:

  1. Go to client spring-api > Client Scopes > Dedicated scope.
  2. 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 KeycloakRoleConverter to map realm_access.roles to Spring authorities
  • Enable @EnableMethodSecurity for @PreAuthorize annotations
  • Use custom authorization beans for complex business rules
  • Spring’s MockMvc with SecurityMockMvcRequestPostProcessors.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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman