- Published on
Keycloak + Spring Security 6 Integration — Resource Server and OAuth2 Client in Practice
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- Resource Server vs OAuth2 Client — Which One Do You Need
- Auto-configuration Based on issuer-uri
- Customizing JwtDecoder — Adding Audience Validation
- Mapping Realm Roles and Client Roles to GrantedAuthority
- Method Security
- Session-based oauth2Login and OIDC Logout
- Multi-tenancy — Accepting Tokens from Multiple Realms in One API
- WebFlux Support
- Testing — spring-security-test and Mock JWT
- Troubleshooting — A 401/403 Debugging Checklist
- Closing Thoughts
- References
Introduction
The Keycloak Spring adapter (keycloak-spring-boot-starter), once the standard, was deprecated years ago and is no longer an option. As of 2026 the answer is unambiguous: treat Keycloak as an ordinary OIDC Provider through the standard OAuth2 stack of Spring Security 6. The standard stack is unaffected by Keycloak version upgrades and meshes naturally with Spring Boot 3.x auto-configuration.
Keycloak 26.6 has further strengthened standards compliance on the token issuance side with the final FAPI 2.0 Security Profile, EdDSA signatures, and the JWT Authorization Grant, while the OAuth 2.1 draft has become the de facto best practice — its principles such as mandatory PKCE and the removal of the implicit flow now align with Spring defaults. This article walks through the entire Keycloak integration, code first, based on Spring Boot 3.4 and Spring Security 6.4.
Resource Server vs OAuth2 Client — Which One Do You Need
The first concept to settle is the difference in roles between the two starters.
| Aspect | oauth2-resource-server | oauth2-client |
|---|---|---|
| Role | API server (token validation) | Web app (login initiator) |
| Auth flow | Receives and validates Bearer tokens | Authorization Code + PKCE |
| Session | Stateless recommended | Session based |
| Token storage | None | OAuth2AuthorizedClientService |
| Typical use | REST APIs, microservices | Server-rendered web, BFF |
The decision rule is simple: if you must redirect the browser to the Keycloak login page, you are a client; if you only validate the Bearer token in the Authorization header, you are a resource server. In a BFF (Backend for Frontend) pattern one application can be both: it handles frontend requests via oauth2Login and propagates the stored access token to downstream API calls.
Dependencies look like this.
dependencies {
// For an API server
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
// For a login-initiating web app
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
Auto-configuration Based on issuer-uri
Spring Boot auto-configuration starts from a single issuer-uri.
# Resource Server
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://kc.example.com/realms/myrealm
At startup Spring appends /.well-known/openid-configuration to the issuer-uri, fetches the OIDC discovery document, and obtains the signature verification keys from the jwks_uri inside it. The automatically constructed JwtDecoder ships with two validators by default.
- Timestamp validation: exp and nbf (with a default 60-second clock skew allowance)
- Issuer validation: whether the iss claim exactly matches the issuer-uri
This is where production incident number one occurs. In environments where containers reach Keycloak through internal DNS while tokens carry the external domain in iss, every request returns 401 due to the iss mismatch. The fix is to split issuer-uri from jwk-set-uri.
spring:
security:
oauth2:
resourceserver:
jwt:
# Basis for iss claim validation (the external domain baked into tokens)
issuer-uri: https://kc.example.com/realms/myrealm
# Actual key retrieval goes over the internal network
jwk-set-uri: http://keycloak.internal:8080/realms/myrealm/protocol/openid-connect/certs
The OAuth2 Client side consists of two blocks: registration and provider.
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: web-app
client-secret: WEB_APP_SECRET
authorization-grant-type: authorization_code
scope: openid, profile, email
provider:
keycloak:
issuer-uri: https://kc.example.com/realms/myrealm
Spring Security 6 applies PKCE automatically for public clients, and confidential clients can run PKCE in parallel via the client-authentication-method setting — defaults that align with the OAuth 2.1 direction.
Customizing JwtDecoder — Adding Audience Validation
The auto-configured JwtDecoder does not validate aud. If you populated aud with the Audience mapper covered in the previous article, that only pays off when you add validation yourself.
@Configuration
public class JwtDecoderConfig {
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
String issuer = props.getJwt().getIssuerUri();
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> issuerValidator =
JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD,
aud -> aud != null && aud.contains("order-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
issuerValidator, audienceValidator));
return decoder;
}
}
On validation failure Spring returns 401 with the error details in the WWW-Authenticate header. Remembering to look at this header rather than the response body will save you time when debugging.
Mapping Realm Roles and Client Roles to GrantedAuthority
The role structure of a Keycloak token differs from the scope-based structure Spring expects. The token payload looks like this.
{
"realm_access": {
"roles": ["platform-admin", "offline_access"]
},
"resource_access": {
"order-api": {
"roles": ["order-viewer", "order-editor"]
}
},
"scope": "openid profile email"
}
The default JwtAuthenticationConverter reads only the scope claim and produces authorities like SCOPE_openid, ignoring realm_access and resource_access. That is why every hasRole check failing with 403 is the classic beginner symptom of Keycloak plus Spring. The full converter code follows.
public class KeycloakJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> {
private static final String CLIENT_ID = "order-api";
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = Stream.concat(
realmRoles(jwt), clientRoles(jwt, CLIENT_ID))
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
String principalName = jwt.getClaimAsString("preferred_username");
return new JwtAuthenticationToken(jwt, authorities, principalName);
}
private Stream<String> realmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return Stream.empty();
}
Object roles = realmAccess.get("roles");
return roles instanceof Collection<?> c
? c.stream().map(Object::toString) : Stream.empty();
}
@SuppressWarnings("unchecked")
private Stream<String> clientRoles(Jwt jwt, String clientId) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess == null) {
return Stream.empty();
}
Object client = resourceAccess.get(clientId);
if (!(client instanceof Map)) {
return Stream.empty();
}
Object roles = ((Map<String, Object>) client).get("roles");
return roles instanceof Collection<?> c
? c.stream().map(Object::toString) : Stream.empty();
}
}
Wire it into the SecurityFilterChain.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("platform-admin")
.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(jwt ->
jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter())));
return http.build();
}
}
One design decision is required: how to distinguish the prefixes of realm roles and client roles. Merging both under the ROLE_ prefix risks name collisions, so at scale it is operationally safer to give client roles a distinct prefix such as ROLE_CLIENT_, or to simplify by using realm roles only.
Method Security
With class-level @EnableMethodSecurity enabled, authorization can be expressed declaratively in the service layer.
@Service
public class OrderService {
@PreAuthorize("hasRole('order-viewer')")
public List<OrderSummary> list(String dept) { ... }
@PreAuthorize("hasRole('order-editor') and #order.dept == authentication.token.claims['dept']")
public Order create(Order order) { ... }
@PostAuthorize("returnObject.ownerId == authentication.name")
public Order findOne(Long id) { ... }
}
As the second example shows, referencing JWT claims directly in SpEL turns the dept claim designed in the previous article into a direct input for data-scoped (row-level) authorization. However, complex SpEL becomes hard to test and trace, so I recommend extracting any condition longer than about three lines into a custom AuthorizationManager bean.
Session-based oauth2Login and OIDC Logout
For a server-rendered web app, configure oauth2Login together with RP-Initiated Logout.
@Configuration
@EnableWebSecurity
public class WebAppSecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http,
ClientRegistrationRepository registrations) throws Exception {
OidcClientInitiatedLogoutSuccessHandler logoutHandler =
new OidcClientInitiatedLogoutSuccessHandler(registrations);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}/logged-out");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/logged-out").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessHandler(logoutHandler));
return http.build();
}
}
Two operational issues come up frequently here.
- Logout that is only local: using the default logout without a logoutSuccessHandler kills only the Spring session while the Keycloak SSO session stays alive, so the next request silently re-authenticates. You must continue the flow to the Keycloak end_session_endpoint via
OidcClientInitiatedLogoutSuccessHandler. - Post-logout redirect rejected: if the redirect target is not registered under Valid post logout redirect URIs in the Keycloak client settings, Keycloak shows an error page. This is easy to miss when deployment domains change.
The reverse direction also matters. To clean up the Spring session when a Keycloak admin force-terminates a session, use OIDC Back-Channel Logout. Spring Security supports it since 6.2 via the oidcLogout DSL; register the endpoint Spring exposes under Backchannel logout URL in the Keycloak client settings.
http.oidcLogout(logout -> logout.backChannel(Customizer.withDefaults()));
Multi-tenancy — Accepting Tokens from Multiple Realms in One API
If your B2B SaaS isolates each tenant in its own realm, the resource server must validate tokens from several issuers at once. The canonical tool is JwtIssuerAuthenticationManagerResolver from Spring Security.
@Configuration
public class MultiTenantSecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Based on an allowlist of issuers — trusted list approach
JwtIssuerAuthenticationManagerResolver resolver =
JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
"https://kc.example.com/realms/tenant-a",
"https://kc.example.com/realms/tenant-b");
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.authenticationManagerResolver(resolver));
return http.build();
}
}
The per-issuer AuthenticationManager is created lazily on first request and cached. If tenants grow dynamically, implement a custom resolver that reads the trusted issuer list from a database — but never trust the iss claim value as-is and run discovery against it. An attacker could send a token whose iss points at their own fake issuer, turning it into an SSRF and token forgery vector; always check against a pre-registered allowlist.
WebFlux Support
On the reactive stack only the types change; the concepts stay the same. Wrap the converter in the reactive adapter.
@Configuration
@EnableWebFluxSecurity
public class ReactiveSecurityConfig {
@Bean
SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
ReactiveJwtAuthenticationConverterAdapter converter =
new ReactiveJwtAuthenticationConverterAdapter(
new KeycloakJwtAuthenticationConverter());
http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(ex -> ex
.pathMatchers("/actuator/health/**").permitAll()
.anyExchange().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(jwt ->
jwt.jwtAuthenticationConverter(converter)));
return http.build();
}
}
When Spring Cloud Gateway serves as the BFF, the standard pattern is oauth2-client plus the TokenRelay filter, propagating the session access token downstream.
spring:
cloud:
gateway:
default-filters:
- TokenRelay=
routes:
- id: order-api
uri: http://order-api.internal:8080
predicates:
- Path=/api/orders/**
Testing — spring-security-test and Mock JWT
Authorization logic must be testable without a running Keycloak for CI to be fast and stable. The jwt() post-processor from spring-security-test is the key tool.
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@Test
void adminEndpointRequiresPlatformAdmin() throws Exception {
mockMvc.perform(get("/api/admin/stats")
.with(jwt()
.jwt(j -> j.claim("preferred_username", "alice")
.claim("dept", "platform"))
.authorities(new SimpleGrantedAuthority("ROLE_platform-admin"))))
.andExpect(status().isOk());
}
@Test
void viewerCannotCreateOrder() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_order-viewer"))))
.andExpect(status().isForbidden());
}
}
The caveat: specifying authorities directly on the jwt() post-processor bypasses your custom converter. To catch regressions in the converter itself, keep a separate test that supplies only raw claims and runs them through the converter.
@Test
void converterMapsRealmAndClientRoles() {
Jwt jwt = Jwt.withTokenValue("t")
.header("alg", "RS256")
.claim("realm_access", Map.of("roles", List.of("platform-admin")))
.claim("resource_access", Map.of("order-api",
Map.of("roles", List.of("order-editor"))))
.claim("preferred_username", "alice")
.build();
var token = new KeycloakJwtAuthenticationConverter().convert(jwt);
assertThat(token.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.containsExactlyInAnyOrder("ROLE_platform-admin", "ROLE_order-editor");
}
To verify the contract with a real Keycloak, I recommend loading a realm export JSON with the Testcontainers Keycloak module and running it at the E2E stage. Split responsibilities: mock JWT for unit and slice tests, Testcontainers for the nightly pipeline.
Troubleshooting — A 401/403 Debugging Checklist
Finally, the most common symptoms and their causes seen in the field.
| Symptom | Most common cause | How to check |
|---|---|---|
| Every request 401 | iss mismatch (internal/external URL mix) | Compare token iss with issuer-uri |
| Every request 401 | aud validation failure (missing mapper) | Inspect WWW-Authenticate header |
| Intermittent 401 | Server clock drift, right after key rotation | Compare exp/iat with server time |
| Authenticated but 403 | Role converter not applied, prefix mismatch | Log the authorities |
| 403 + CSRF error | CSRF enabled on a stateless API | Check whether only POST fails |
| Auto re-login after logout | RP-Initiated Logout not configured | Check surviving Keycloak session |
The first debugging step is always the same: turn on security logging.
logging:
level:
org.springframework.security: TRACE
org.springframework.security.oauth2: TRACE
At TRACE level you see exactly which filter rejected the request, which validator the JwtDecoder failed on, and what authorities the converter produced. Simply internalizing that 401 is an authentication problem (the token itself) and 403 is an authorization problem (role mapping) cuts the search space in half. For a 401, decode the token and inspect iss, aud, and exp; for a 403, inspect the converter and the casing and prefix of hasRole expressions — in that order.
Closing Thoughts
Handling Keycloak through the standard Spring Security 6 stack frees you completely from the version-coupling problems of the adapter era. Key takeaways:
- Validate tokens only — resource server. Drive login — oauth2-client. A BFF combines both.
- issuer-uri auto-configuration covers iss validation only. Add aud validation yourself by customizing JwtDecoder.
- realm_access and resource_access roles must be mapped to GrantedAuthority via a custom JwtAuthenticationConverter for hasRole to work.
- Configure logout in both directions: RP-Initiated Logout and Back-Channel Logout.
- Handle multi-tenancy with a trusted-issuer allowlist resolver, and structure tests in two tiers: mock JWT plus Testcontainers.
The next article covers building social login and external IdP federation with Keycloak Identity Brokering.
References
- Spring Security Reference — OAuth2 Resource Server
- Spring Security Reference — OAuth2 Client
- Spring Security Reference — OIDC Logout
- Spring Security Reference — Multitenancy
- Spring Security Reference — Testing OAuth2
- Keycloak Documentation — Securing Applications
- Keycloak Release Notes
- OpenID Connect Core 1.0
- OpenID Connect RP-Initiated Logout 1.0
- OpenID Connect Back-Channel Logout 1.0
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- OAuth 2.1 draft