Spring Security und Keycloak: OAuth2 Resource Server einrichten
Authentifizierung als zentraler Baustein
In fast jedem produktiven Backend-Service wird irgendwann Authentifizierung und Autorisierung relevant. Seit wir Keycloak als zentralen Identity Provider in unserem Self-Hosted Stack betreiben, haben wir ein einheitliches Muster für die Absicherung unserer Spring-Boot-Services etabliert. Die Kombination aus Keycloak als OAuth2/OIDC-Provider und Spring Security als Resource Server ist mittlerweile der Standard in unseren Java- und Kotlin-Projekten. In diesem Beitrag zeigen wir, wie das konkret aussieht.
Die Ausgangslage
Wir arbeiten mit Spring Boot 3.4 und Spring Security 6.4, Java 21 und Keycloak 24. Die Architektur ist ein klassisches Setup: Keycloak verwaltet Benutzer, Rollen und stellt JWT-Tokens aus. Unsere Spring-Boot-Services agieren als OAuth2 Resource Server -- sie nehmen Requests mit einem Bearer-Token entgegen, validieren das JWT gegen Keycloak und extrahieren die Rollen für die Autorisierung.
Für das Deployment von Keycloak auf Kubernetes nutzen wir das codecentric Keycloakx Helm Chart, nachdem das frühere Bitnami-Chart deprecated wurde.
Die nötigen Dependencies im 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")
}
Und die Keycloak-Konfiguration in der 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
Mehr braucht es für die Grundkonfiguration nicht. Spring Security lädt die öffentlichen Schlüssel automatisch vom JWK-Set-Endpoint und validiert damit die Signatur jedes eingehenden Tokens.
Security-Konfiguration als SecurityFilterChain
Seit Spring Boot 3.0 ist der WebSecurityConfigurerAdapter Geschichte. Die Security-Konfiguration erfolgt über 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();
}
}
Drei Dinge sind hier relevant: Erstens deaktivieren wir CSRF, weil unsere Services als stateless REST-APIs arbeiten und Tokens im Authorization-Header transportieren. Zweitens definieren wir, welche Pfade ohne Authentifizierung erreichbar sind. Drittens konfigurieren wir den Resource Server mit einem eigenen JWT-Converter, der die Keycloak-Rollen korrekt extrahiert.
JWT-Converter: Rollen aus Keycloak extrahieren
Keycloak legt Realm-Rollen im JWT standardmäßig unter realm_access.roles ab -- nicht dort, wo Spring Security sie erwartet. Ohne eigenen Converter würde Spring Security die Rollen schlicht ignorieren. Der folgende Converter liest die Rollen aus der Keycloak-Struktur und wandelt sie in Spring-Security-GrantedAuthorities um:
@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;
}
Wir präfixen jede Rolle mit ROLE_, weil Spring Security das für hasRole()-Ausdrücke erwartet. Wer hasAuthority() nutzt, kann das Präfix weglassen. Wer neben Realm-Rollen auch Client-spezifische Rollen aus Keycloak braucht, kann den Converter erweitern -- in Keycloak liegen diese unter resource_access.<client-id>.roles.
Method-Level Security mit @PreAuthorize
Die globale URL-basierte Konfiguration deckt die Grundabsicherung ab. Für feingranulare Autorisierung setzen wir auf @PreAuthorize an den Controller-Methoden:
@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();
}
}
Die @PreAuthorize-Annotationen machen sofort sichtbar, welche Rolle für welche Aktion erforderlich ist. Im letzten Endpunkt zeigen wir, wie man über JwtAuthenticationToken direkt auf das JWT zugreift -- etwa um die User-ID aus dem sub-Claim zu extrahieren.
Tests mit @WithMockUser und jwt()
Spring Security bietet mit spring-security-test ausgezeichnete Testunterstützung:
@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 erstellt einen simulierten Security-Context mit den angegebenen Rollen. Für realistischere Tests mit JWT-Claims nutzen wir SecurityMockMvcRequestPostProcessors.jwt(), wie im letzten Test gezeigt.
Keycloak in der Entwicklung
Für die lokale Entwicklung starten wir Keycloak über Docker Compose. Wir exportieren unsere Realm-Konfiguration als JSON und importieren sie beim Start automatisch:
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"
Damit hat jeder Entwickler eine identische Keycloak-Instanz mit denselben Rollen und Test-Benutzern.
Was wir gelernt haben
Nach mehreren Projekten mit diesem Setup haben sich einige Lektionen herauskristallisiert:
- Rollen-Granularität früh definieren: Welche Rollen es gibt und was sie dürfen, ist keine rein technische Entscheidung. Je früher das geklärt ist, desto weniger Umbau später.
- Realm-Rollen vs. Client-Rollen: Realm-Rollen für übergreifende Konzepte wie
adminoderuser, Client-Rollen für service-spezifische Berechtigungen. Wir starten mit Realm-Rollen und differenzieren erst bei Bedarf. - Token-Größe beachten: Keycloak packt viele Informationen ins JWT. Bei vielen Rollen und Gruppen kann das Token überraschend groß werden.
- Testabdeckung für Security: Jeder Endpunkt sollte mindestens einen Test haben, der prüft, ob ein unauthentifizierter Zugriff korrekt abgewiesen wird.
Fazit
Die Kombination aus Spring Security 6 und Keycloak funktioniert zuverlässig in unseren Java- und Kotlin-Projekten gleichermaßen. Die Sprache spielt bei der Security-Konfiguration letztlich keine Rolle -- die Patterns sind identisch. Für uns schließt sich damit ein Kreis: Spring Boot 3 als Framework, Keycloak als zentraler Identity Provider, und Spring Security als bewährter Baustein, der beides verbindet.
Geschrieben von
Patrick HütterGründer & Software-Architekt
Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.
Das könnte dich auch interessieren
Vom Manifest zur Produktion: Wie ADL, A2A und das Inference Gateway die Agenten-Infrastruktur revolutionieren
05.07.2026 · 12 Min. Lesezeit
Agent-Orchestrierung mit Java: Wie Sie LLM-Agenten produktionsreif in die JVM bringen
04.07.2026 · 6 Min. Lesezeit
Spring AI: Wie Java-Entwickler endlich KI-Features ohne Umwege integrieren
25.03.2026 · 5 Min. Lesezeit