Spring Security and Keycloak: Setting Up an OAuth2 Resource Server
Authentication as a Core Building Block
In almost every production backend service, authentication and authorization become relevant at some point. Since we started running Keycloak as our central identity provider in our self-hosted stack, we have established a consistent pattern for securing our Spring Boot services. The combination of Keycloak as an OAuth2/OIDC provider and Spring Security as a resource server has become the standard in our Java and Kotlin projects. In this post, we show what this looks like in practice.
The Starting Point
We work with Spring Boot 3.4 and Spring Security 6.4, Java 21 and Keycloak 24. The architecture is a classic setup: Keycloak manages users and roles and issues JWT tokens. Our Spring Boot services act as OAuth2 Resource Servers -- they accept requests with a Bearer token, validate the JWT against Keycloak, and extract the roles for authorization.
For deploying Keycloak on Kubernetes, we use the codecentric Keycloakx Helm chart, after the previous Bitnami chart was deprecated.
The required dependencies in build.gradle.kts:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
testImplementation("org.springframework.security:spring-security-test")
}
And the Keycloak configuration in application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/encircle360
jwk-set-uri: https://auth.example.com/realms/encircle360/protocol/openid-connect/certs
That is all it takes for the basic configuration. Spring Security automatically loads the public keys from the JWK Set endpoint and uses them to validate the signature of every incoming token.
Security Configuration as SecurityFilterChain
Since Spring Boot 3.0, the WebSecurityConfigurerAdapter is history. Security configuration is done through SecurityFilterChain beans:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(keycloakJwtAuthenticationConverter())
)
);
return http.build();
}
}
Three things are relevant here: First, we disable CSRF because our services operate as stateless REST APIs and transport tokens in the Authorization header. Second, we define which paths are accessible without authentication. Third, we configure the resource server with a custom JWT converter that correctly extracts the Keycloak roles.
JWT Converter: Extracting Roles from Keycloak
Keycloak stores realm roles in the JWT by default under realm_access.roles -- not where Spring Security expects them. Without a custom converter, Spring Security would simply ignore the roles. The following converter reads the roles from the Keycloak structure and converts them into Spring Security GrantedAuthorities:
@Bean
public JwtAuthenticationConverter keycloakJwtAuthenticationConverter() {
Converter<Jwt, Collection<GrantedAuthority>> grantedAuthoritiesConverter = jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles == null) {
return Collections.emptyList();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
};
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
We prefix each role with ROLE_ because Spring Security requires this for hasRole() expressions. If you use hasAuthority(), you can omit the prefix. If you need client-specific roles from Keycloak in addition to realm roles, you can extend the converter -- in Keycloak, these are located under resource_access.<client-id>.roles.
Method-Level Security with @PreAuthorize
The global URL-based configuration covers the basic security. For fine-grained authorization, we use @PreAuthorize on the controller methods:
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
private final ProjectService projectService;
public ProjectController(ProjectService projectService) {
this.projectService = projectService;
}
@GetMapping
@PreAuthorize("hasRole('user')")
public List<ProjectResponse> listProjects() {
return projectService.findAll().stream()
.map(ProjectResponse::from)
.toList();
}
@PostMapping
@PreAuthorize("hasRole('admin')")
public ProjectResponse createProject(@Valid @RequestBody CreateProjectRequest request) {
return ProjectResponse.from(projectService.create(request));
}
@GetMapping("/me")
public List<ProjectResponse> myProjects(JwtAuthenticationToken authentication) {
String userId = authentication.getToken().getSubject();
return projectService.findByUserId(userId).stream()
.map(ProjectResponse::from)
.toList();
}
}
The @PreAuthorize annotations make it immediately visible which role is required for which action. In the last endpoint, we show how to access the JWT directly via JwtAuthenticationToken -- for example, to extract the user ID from the sub claim.
Testing with @WithMockUser and jwt()
Spring Security offers excellent testing support with spring-security-test:
@WebMvcTest(ProjectController.class)
class ProjectControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProjectService projectService;
@Test
@WithMockUser(roles = "user")
void listProjects_returnsOk() throws Exception {
when(projectService.findAll()).thenReturn(List.of(testProject()));
mockMvc.perform(get("/api/projects")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@WithMockUser(roles = "user")
void createProject_returnsForbiddenForNonAdmin() throws Exception {
mockMvc.perform(post("/api/projects")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Test Project"}
"""))
.andExpect(status().isForbidden());
}
@Test
void listProjects_returnsUnauthorizedWithoutAuth() throws Exception {
mockMvc.perform(get("/api/projects"))
.andExpect(status().isUnauthorized());
}
@Test
void myProjects_extractsUserIdFromJwt() throws Exception {
when(projectService.findByUserId("user-123")).thenReturn(List.of(testProject()));
mockMvc.perform(get("/api/projects/me")
.with(jwt().jwt(builder -> builder.subject("user-123"))))
.andExpect(status().isOk());
}
}
@WithMockUser creates a simulated security context with the specified roles. For more realistic tests with JWT claims, we use SecurityMockMvcRequestPostProcessors.jwt(), as shown in the last test.
Keycloak in Development
For local development, we start Keycloak via Docker Compose. We export our realm configuration as JSON and import it automatically on startup:
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json
ports:
- "8180:8080"
This gives every developer an identical Keycloak instance with the same roles and test users.
What We Have Learned
After several projects with this setup, a few lessons have crystallized:
- Define role granularity early: Which roles exist and what they are allowed to do is not a purely technical decision. The earlier this is clarified, the less restructuring later.
- Realm roles vs. client roles: Realm roles for cross-cutting concepts like
adminoruser, client roles for service-specific permissions. We start with realm roles and differentiate only when needed. - Watch token size: Keycloak packs a lot of information into the JWT. With many roles and groups, the token can become surprisingly large.
- Test coverage for security: Every endpoint should have at least one test that verifies that unauthenticated access is correctly rejected.
Conclusion
The combination of Spring Security 6 and Keycloak works reliably in our Java and Kotlin projects alike. The language ultimately does not matter for the security configuration -- the patterns are identical. For us, this closes a circle: Spring Boot 3 as the framework, Keycloak as the central identity provider, and Spring Security as the proven building block that connects both.
Written by
Patrick HütterFounder & Software Architect
Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.
You might also like
From Manifest to Production: How ADL, A2A and the Inference Gateway Are Revolutionizing Agent Infrastructure
Jul 5, 2026 · 12 min read
Agent Orchestration with Java: Bringing LLM Agents to Production on the JVM
Jul 4, 2026 · 6 min read
Spring AI: How Java Developers Can Finally Integrate AI Features the Right Way
Mar 25, 2026 · 5 min read