- Published on
Keycloak + Spring Security 6 統合 — Resource Server と OAuth2 Client の実践
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- Resource Server vs OAuth2 Client — どちらを使うべきか
- issuer-uri ベースの自動設定
- JwtDecoder のカスタマイズ — audience 検証の追加
- Realm ロールと Client ロールを GrantedAuthority にマッピング
- Method Security
- セッションベースの oauth2Login と OIDC ログアウト
- マルチテナント — 複数 Realm のトークンを 1 つの API で受ける
- WebFlux 対応
- テスト — spring-security-test と Mock JWT
- トラブルシューティング — 401/403 デバッグチェックリスト
- おわりに
- 参考資料
はじめに
かつて標準だった Keycloak Spring アダプター(keycloak-spring-boot-starter)はすでに数年前に deprecated となり、もはや選択肢ではありません。2026 年現在の正解は明確です。Spring Security 6 の標準 OAuth2 スタックで Keycloak を普通の OIDC Provider として扱うことです。標準スタックは Keycloak のバージョンアップの影響を受けず、Spring Boot 3.x の自動設定とも自然に噛み合います。
Keycloak 26.6 は FAPI 2.0 Security Profile Final、EdDSA 署名、JWT Authorization Grant など、トークン発行側の標準準拠を一段と強化しました。さらに OAuth 2.1 draft が事実上のベストプラクティスとして定着し、PKCE の必須化や Implicit フローの廃止といった原則が Spring のデフォルトとも一致するようになりました。本記事は Spring Boot 3.4 + Spring Security 6.4 を前提に、Keycloak 統合の全工程をコード中心に整理します。
Resource Server vs OAuth2 Client — どちらを使うべきか
最初に整理すべき概念は、2 つのスターターの役割の違いです。
| 項目 | oauth2-resource-server | oauth2-client |
|---|---|---|
| 役割 | API サーバー(トークン検証) | Web アプリ(ログイン主体) |
| 認証フロー | Bearer トークンの受信・検証 | Authorization Code + PKCE |
| セッション | ステートレス推奨 | セッションベース |
| トークン保管 | 保管しない | OAuth2AuthorizedClientService |
| 代表的な用途 | REST API、マイクロサービス | サーバーレンダリング Web、BFF |
判断基準はシンプルです。ブラウザを Keycloak のログインページへリダイレクトする必要があれば client、Authorization ヘッダーの Bearer トークンを検証するだけなら resource server です。BFF(Backend for Frontend)パターンなら、1 つのアプリケーションが両方を兼ねることもあります。フロントのリクエストは oauth2Login で受け、ダウンストリームの API 呼び出しには保管した access token を伝搬する構成です。
依存関係は次のとおりです。
dependencies {
// API サーバーの場合
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
// ログイン主体の Web アプリの場合
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
issuer-uri ベースの自動設定
Spring Boot の自動設定は issuer-uri 1 つから始まります。
# Resource Server
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://kc.example.com/realms/myrealm
起動時に Spring は issuer-uri の後ろに /.well-known/openid-configuration を付けて OIDC discovery ドキュメントを取得し、その中の jwks_uri から署名検証鍵を取得します。このとき自動構成される JwtDecoder には 2 つの検証器がデフォルトで組み込まれます。
- タイムスタンプ検証: exp、nbf(デフォルトで 60 秒の clock skew を許容)
- issuer 検証: iss クレームが issuer-uri と完全一致するか
ここで実務の事故第 1 号が発生します。コンテナ内部では Keycloak へ内部 DNS でアクセスし、トークンの iss には外部ドメインが入る環境では、iss 不一致によりすべてのリクエストが 401 になります。解決策は issuer-uri と jwk-set-uri を分離することです。
spring:
security:
oauth2:
resourceserver:
jwt:
# iss クレーム検証の基準(トークンに刻まれた外部ドメイン)
issuer-uri: https://kc.example.com/realms/myrealm
# 実際の鍵取得は内部ネットワーク経由
jwk-set-uri: http://keycloak.internal:8080/realms/myrealm/protocol/openid-connect/certs
OAuth2 Client 側の設定は registration と provider の 2 ブロックで構成されます。
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 は public client に対して PKCE を自動適用し、confidential client も client-authentication-method の設定で PKCE を併用できます。OAuth 2.1 の方向性と一致するデフォルトです。
JwtDecoder のカスタマイズ — audience 検証の追加
自動構成された JwtDecoder は aud を検証しません。前回の記事で扱った Audience mapper でトークンに aud を入れたなら、検証も自分で追加して初めて意味があります。
@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;
}
}
検証失敗時、Spring は 401 とともに WWW-Authenticate ヘッダーにエラーの詳細を入れて返します。デバッグの際はレスポンスボディではなくこのヘッダーを見るべきだと覚えておくと、時間を節約できます。
Realm ロールと Client ロールを GrantedAuthority にマッピング
Keycloak トークンのロール構造は、Spring が期待する scope ベースの構造と異なります。トークンのペイロードは次のようになっています。
{
"realm_access": {
"roles": ["platform-admin", "offline_access"]
},
"resource_access": {
"order-api": {
"roles": ["order-viewer", "order-editor"]
}
},
"scope": "openid profile email"
}
デフォルトの JwtAuthenticationConverter は scope クレームだけを読んで SCOPE_openid のような権限を作るのみで、realm_access と resource_access は無視します。そのため hasRole チェックがすべて 403 になるのが、Keycloak + Spring 入門者の定番症状です。変換器の全コードは次のとおりです。
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();
}
}
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();
}
}
1 つ設計上の判断が必要です。realm ロールと client ロールの prefix をどう区別するかです。両方を ROLE_ prefix に統合すると名前衝突の可能性があるため、規模が大きい場合は client ロールに ROLE_CLIENT_ のような別 prefix を与えるか、realm ロールだけを使うポリシーに単純化するのが運用上有利です。
Method Security
クラスレベルの @EnableMethodSecurity を有効にすれば、サービス層で宣言的に認可を表現できます。
@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) { ... }
}
2 番目の例のように SpEL で JWT クレームを直接参照すれば、前回の記事で設計した dept クレームがそのままデータ範囲認可(row-level authorization)の入力になります。ただし SpEL が複雑になるとテストと追跡が困難になるため、3 行を超える条件はカスタム AuthorizationManager Bean に抽出することをおすすめします。
セッションベースの oauth2Login と OIDC ログアウト
サーバーレンダリングの Web アプリなら、oauth2Login と 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();
}
}
ここで頻発する運用上の問題が 2 つあります。
- ローカルログアウトしかされない問題: logoutSuccessHandler なしのデフォルト logout だけでは Spring のセッションが切れるだけで、Keycloak の SSO セッションは生きており、次のリクエストで即座に自動再ログインされます。必ず
OidcClientInitiatedLogoutSuccessHandlerで Keycloak の end_session_endpoint までフローをつなぐ必要があります。 - post logout redirect の拒否: Keycloak クライアント設定の Valid post logout redirect URIs にリダイレクト先が登録されていないと、Keycloak がエラーページを表示します。デプロイドメイン変更時に見落としやすい項目です。
逆方向のログアウトも考慮すべきです。Keycloak 管理者がセッションを強制終了したときに Spring のセッションも一緒に整理するには、OIDC Back-Channel Logout を使います。Spring Security 6.2 から oidcLogout DSL でサポートされており、Keycloak クライアント設定の Backchannel logout URL に Spring が公開するエンドポイントを登録すれば完了です。
http.oidcLogout(logout -> logout.backChannel(Customizer.withDefaults()));
マルチテナント — 複数 Realm のトークンを 1 つの API で受ける
B2B SaaS でテナントごとに realm を分離している場合、リソースサーバーは複数の issuer のトークンを同時に検証する必要があります。Spring Security が提供する JwtIssuerAuthenticationManagerResolver が定石です。
@Configuration
public class MultiTenantSecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 許可された issuer のリストに基づく — 信頼リスト方式
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();
}
}
issuer ごとの AuthenticationManager は初回リクエスト時に lazy に生成されキャッシュされます。テナントが動的に増える環境では、trusted issuer のリストを DB から読むカスタム resolver を実装すべきですが、iss クレームの値をそのまま信頼して discovery を実行してはいけません。攻撃者が自分の偽 issuer を iss に入れたトークンを送る SSRF・トークン偽造のベクターになるため、必ず事前登録された許可リストと照合する必要があります。
WebFlux 対応
リアクティブスタックでは型が変わるだけで、概念は同じです。変換器は Reactive アダプターでラップします。
@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();
}
}
Spring Cloud Gateway を BFF として使う場合は、oauth2-client + TokenRelay フィルターの組み合わせで、セッションの access token をダウンストリームへ伝搬するパターンが標準です。
spring:
cloud:
gateway:
default-filters:
- TokenRelay=
routes:
- id: order-api
uri: http://order-api.internal:8080
predicates:
- Path=/api/orders/**
テスト — spring-security-test と Mock JWT
認可ロジックは Keycloak なしでテストできてこそ、CI が高速かつ安定します。spring-security-test の jwt() ポストプロセッサーが中核ツールです。
@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());
}
}
注意すべき点は、jwt() ポストプロセッサーで authorities を直接指定するとカスタム変換器をバイパスすることです。変換器自体のリグレッションを捕捉するには、raw クレームだけを入れて変換器を通すテストを別途用意します。
@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");
}
本物の Keycloak との契約を検証したい場合は、Testcontainers の Keycloak モジュールで realm export JSON をロードし、E2E ステージで実行する構成をおすすめします。ユニット・スライステストは mock JWT、夜間パイプラインは Testcontainers と役割を分けるのです。
トラブルシューティング — 401/403 デバッグチェックリスト
最後に、現場で最も多く遭遇する症状別の原因を整理します。
| 症状 | 最も多い原因 | 確認方法 |
|---|---|---|
| すべてのリクエストが 401 | iss 不一致(内部/外部 URL の混用) | トークンの iss と issuer-uri を比較 |
| すべてのリクエストが 401 | aud 検証失敗(mapper の欠落) | WWW-Authenticate ヘッダーを確認 |
| 断続的な 401 | サーバーの時計ドリフト、鍵ローテーション直後 | exp/iat とサーバー時刻を比較 |
| 認証は通るが 403 | ロール変換器の未適用、prefix 不一致 | authorities をログ出力 |
| 403 + CSRF エラー | ステートレス API で CSRF が有効 | POST だけ 403 か確認 |
| ログアウト後に自動再ログイン | RP-Initiated Logout 未構成 | Keycloak セッションの残存を確認 |
デバッグの第一歩はいつも同じです。セキュリティログを有効にすることです。
logging:
level:
org.springframework.security: TRACE
org.springframework.security.oauth2: TRACE
TRACE レベルでは、どのフィルターがリクエストを拒否したか、JwtDecoder がどの検証器で失敗したか、変換された authorities が何かがすべて出力されます。401 は認証(トークン自体)の問題、403 は認可(権限マッピング)の問題という区別を確実にするだけで、原因の探索範囲が半分になります。401 ならトークンをデコードして iss・aud・exp を確認し、403 なら変換器と hasRole 式の大文字小文字・prefix を確認するのが定石の順序です。
おわりに
Spring Security 6 の標準スタックで Keycloak を扱えば、アダプター時代のバージョン依存問題から完全に解放されます。要点をまとめます。
- トークンを検証するだけなら resource server、ログインを主導するなら oauth2-client。BFF は両者の組み合わせです。
- issuer-uri の自動設定がカバーするのは iss 検証までです。aud 検証は JwtDecoder のカスタマイズで自分で追加します。
- realm_access / resource_access のロールはカスタム JwtAuthenticationConverter で GrantedAuthority にマッピングしてこそ hasRole が動作します。
- ログアウトは RP-Initiated Logout と Back-Channel Logout の双方向で構成します。
- マルチテナントは trusted issuer 許可リストベースの resolver で、テストは mock JWT と Testcontainers の二層構造で進めます。
次回の記事では、Keycloak の Identity Brokering によるソーシャルログインと外部 IdP 連携の構築方法を扱います。
参考資料
- 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