Skip to content

필사 모드: Keycloak + Spring Security 6 Integration — Resource Server and OAuth2 Client in Practice

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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](https://www.keycloak.org/docs/latest/release_notes/index.html) 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](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/) 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](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html)

- [Spring Security Reference — OAuth2 Client](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html)

- [Spring Security Reference — OIDC Logout](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/logout.html)

- [Spring Security Reference — Multitenancy](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/multitenancy.html)

- [Spring Security Reference — Testing OAuth2](https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/oauth2.html)

- [Keycloak Documentation — Securing Applications](https://www.keycloak.org/docs/latest/securing_apps/index.html)

- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)

- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)

- [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)

- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)

- [RFC 6749 — The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)

- [RFC 7636 — Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)

- [RFC 9700 — Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)

- [OAuth 2.1 draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)

현재 단락 (1/295)

The Keycloak Spring adapter (keycloak-spring-boot-starter), once the standard, was deprecated years ...

작성 글자: 0원문 글자: 16,667작성 단락: 0/295