ステータス:ドラフト(2026-10-26)、実測反映。 実測は3つあります。① TT LabクラスターのKeycloakから、誰でも読み込める公開メタデータ(
/.well-known/openid-configuration、JWKS)を読み込みました。ホスト・レルム名とバージョン番号はマスクしています。② ノートPCに 実習用Keycloak(devモード、本番とは別の実習環境) を新しく立ち上げ、実習用レルム・クライアント・ユーザーをランダムな値で作成して、Authorization Code + PKCEをPythonの標準ライブラリのみで実行しました。実際のユーザーアカウントではログインしていません。③ 実習用キーで作成したJWTをローカルのゲートウェイ(agentgateway)に送信して判定を確認しました。実習用キーの秘密鍵部分は記事に含めていません。仕様は同日の原文を開いて確認しました。 作成ルール:実測データを基本とし、測定できなかったものは「未実測」、確認できなかったものは「確認できず」、推測は「推定」と記載します。
目次
- 1. 入館証は1カ所で、確認はどこで?
- 2. OAuth 2.0、OIDC、SAML:何が何を解決するのか
- 3. Authorization Code + PKCE:トークンをブラウザに経由させない
- 4. 3つのトークンとJWT署名検証
- 5. エージェント(SDK)方式:アプリ内のライブラリが行うこと
- 6. ゲートウェイ方式:アプリの前で代わりに確認する
- 7. ログアウトとセッション同期
- 8. よくある罠
- 9. どちらを選ぶべきか
- 10. まとめ
- 参考
1. 入館証は1カ所で、確認はどこで?
会社のビルを想像してみましょう。入館証はセキュリティルームという1カ所で発行されます。これが IdP(Identity Provider、例:Keycloak)です。入館証を確認する方法は2つあります。
- オフィスの扉ごとにリーダーを取り付ける。 オフィス(アプリケーション)が自分で入館証を読み取り、誰が入ってきたのか・どのような権限を持つのかを直接把握します。これが エージェント(SDK)方式です。アプリプロセス内のライブラリが、ログインのリダイレクト、コールバック、セッション、トークンの更新、ログアウトを処理します。
- ビルの入口に警備員を配置する。 入口(ゲートウェイ)が入館証を確認し、通過した人に対して「この人は誰か」というメモを持たせて中に入れます。オフィスはそのメモだけを読み取ります。これが ゲートウェイ(プロキシ)方式です。oauth2-proxy、EnvoyのJWTフィルター、agentgatewayなどがアプリの前でこの役割を担います。
2つの方式は 同じ規約(OIDC) を使用します。異なるのは、その規約を どのプロセスが実行するのかだけです。そのため、まず規約を確認し(第2〜4章)、それをアプリ内で行う場合(第5章)とアプリ前で行う場合(第6章)を比較したのち、ログアウト(第7章)と罠(第8章)に進みます。
2. OAuth 2.0、OIDC、SAML:何が何を解決するのか
| OAuth 2.0 | OpenID Connect (OIDC) | SAML 2.0 | |
|---|---|---|---|
| 解決する課題 | 認可の委任:アプリがユーザーの代わりにAPIを呼び出す権限 | 認証:「この人は誰か」をアプリに通知 | 認証(および属性伝送)、主に企業Web SSO |
| 成果物 | アクセストークン(形式自由) | IDトークン(必ずJWT) + OAuthのトークン類 | 署名付きXML Assertion |
| 位置づけ | ベース | OAuth 2.0の上に構築されたレイヤー | 別個の規約(2005) |
| ディスカバリー | — | /.well-known/openid-configuration(Discovery) |
メタデータXML |
| 伝送 | リダイレクト + バックチャネル /token |
同上 | HTTP-Redirect(リクエスト)・HTTP-POST(レスポンス)バインディング |
OAuth 2.0だけでは「ログイン」ができないという点が重要です。アクセストークンはAPIのためのものであり、「誰がログインしたのか」をアプリに証明する文書ではないからです。OIDCはその空白を IDトークンで埋めます。OIDC Core §2では、IDトークンに iss·sub·aud·exp·iat を含めることが義務付けられています。
SAMLのSP起点フローも形状は似ています。SP(アプリ)が AuthnRequest を圧縮してリダイレクトで送信し、IdPがログインさせた後に 署名された Response をHTMLフォームのPOSTでアプリのACSアドレスに渡します(OASIS SAML 2.0技術概要 §5.1.2、§4.6)。この記事では、新規構築する連携で主に使用されるOIDCを中心に見ていきます。SAMLの規範文書(core・bindings PDF)は本文を直接引用できず、概要文書で確認しました。
3. Authorization Code + PKCE:トークンをブラウザに経由させない
フローの核心は、 ブラウザを介して渡るのは短命なcodeだけであり、トークンはアプリとIdPがバックチャネルで直接やり取りするということです。ブラウザのアドレスバー、履歴、リファラー、拡張機能はすべて情報漏洩の経路となるためです。以前のimplicitフローでは、トークンをアドレスの #fragment でブラウザに直接渡していましたが、OAuthセキュリティBCP(RFC 9700、2025-01)§2.1.2では、もはやimplicitを使用しないよう求めています。同文書では、アプリがパスワードを受け取ってIdPに渡すpassword grantも禁止(§2.4)しています。
3つの乱数:state、nonce、PKCE
1つのログインリクエストに3つの乱数が紐づきます。それぞれ役割が異なります。
| 乱数 | どこで確認するか | 防ぐもの |
|---|---|---|
state |
コールバックでアプリが | 第三者が作成したコールバックを自分のブラウザに強制挿入するCSRF |
nonce |
IDトークン内でアプリが | 他のログインのIDトークンをリプレイすること |
PKCE code_verifier |
/token でIdPが |
横取りされたcodeを別人がトークンに交換すること |
PKCE(RFC 7636)の計算は1行です。アプリが43〜128文字の乱数 code_verifier を作成し(§4.1)、ログインリクエストにはそのハッシュ code_challenge = BASE64URL(SHA256(code_verifier)) のみを送信します(§4.2、S256)。codeをトークンに交換する際には元のverifierを提示する必要があるため、codeだけを盗んだ者には何もできません。
import base64, hashlib, secrets
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
verifier = b64u(secrets.token_bytes(32)) # 43자
challenge = b64u(hashlib.sha256(verifier.encode()).digest())
print(b64u(hashlib.sha256(b"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").digest()))
最後の行はRFC 7636付録Bのサンプルverifierです。ローカルで実行したところ E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM となり、付録の期待値と一致しました(2026-10-26)。RFC 9700 §2.1.1は、パブリッククライアント(秘密情報を保持できないSPA・モバイルアプリ)に対してPKCEを 必ず 使用するよう義務付けており、秘密情報を保持するサーバーアプリに対しても推奨しています。
実測:ローカルのKeycloakで1存
実習用Keycloakに秘密情報を持つ(confidential)クライアントを1つ作成し、PKCEをS256に強制し、redirect URIを正確に1つだけ登録しました。その後、ブラウザの役割をPythonの urllib で模倣し、上記図の③〜⑨をそのまま実行しました。
| ステップ | 結果(2026-10-26、ノートPC、ローカルIdP) |
|---|---|
| ③〜⑥ ログイン画面 → パスワード送信 → コールバック | 117〜122ms。コールバックに code·state·session_state·iss 4つの値 |
| codeの長さ | 110文字 |
⑦–⑧ /token 交換 |
200、9ms。取得した内容: access_token·id_token·refresh_token·expires_in など9個のキー |
| 同じcodeをもう一度 | 400 invalid_grant 「Code not valid」 |
| verifierを間違えた場合 | 400 invalid_grant 「PKCE verification failed: Code mismatch」 |
| ログインリクエストからPKCEを外すと | IdPがコールバックとして invalid_request 「Missing parameter: code_challenge_method」 |
redirect_uriの末尾に / を1つ追加すると |
400エラー画面「Invalid parameter: redirect_uri」、コールバックにも戻らない |
コールバックに iss が付与されて返ってくるのは、IdPが authorization_response_iss_parameter_supported を有効にしているためです。アプリが複数のIdPを使用している際に、「このcodeをどちらのIdPが送信したのか」を混同させるミックスアップ攻撃を防ぎます。
1つの挙動は予想以上に強力でした。 同じcodeを再利用すると、そのcodeですでに取得済みのトークンのセッションまで切断されました。 再利用を試みた直後にそのセッションのアクセストークンをintrospectionすると active: falseとなり、リフレッシュトークンで更新しようとすると400「Session doesn't have required client」となりました。codeが2回使用されたということは誰かがcodeを横取りしたというシグナルであるため、すでに発行したトークンも信用しないという動作です。RFC 6749 §4.1.2が推奨しているまさにその処理です。ただし、そのアクセストークンは 署名検証のみを行う側では依然として有効でした(第7章の問題と同様です)。
4. 3つのトークンとJWT署名検証
| トークン | 受け取る側 | 何に使用するか | 実測寿命(実習レルムの設定) | 実測サイズ |
|---|---|---|---|---|
| IDトークン | アプリ(クライアント) | 「誰がログインしたか」。アプリセッション作成時に1回 | 300秒 | 1,145バイト |
| アクセストークン | API(リソースサーバー) | API呼び出しの Authorization: Bearer |
300秒 | 1,403バイト |
| リフレッシュトークン | IdPのみ | 新しいアクセストークンの取得 | 1,800秒(SSOセッションのアイドル上限と同じ) | — |
アクセストークンとIDトークンは、1つのCookie(通常4KB制限)の3分の1以上を占めます。ゲートウェイがトークンをCookieに格納する方式を採用する場合、複数のCookieに分割するか、サーバー側のセッションストアを使用する必要がある理由がここにあります。
JWTの3つのパーツ
JWT(RFC 7519)はJWS(RFC 7515)のコンパクト形式であり、 BASE64URL(헤더).BASE64URL(페이로드).BASE64URL(서명) です。 base64urlは暗号化ではありません。 誰でもデコードして読み取ることができるため、トークンに秘密情報を入れてはなりません。実測したトークンのヘッダーは {"alg":"RS256","typ":"JWT","kid":"…"} であり、 kid はIdPの公開鍵リスト(JWKS、RFC 7517)の中からどの鍵で検証すべきかを選択するための識別子です。
RS256の検証は結局のところべき乗計算1回です。署名sを公開鍵(n, e)で s^e mod n すると、PKCS#1 v1.5の形状(00 01 FF … FF 00 + DigestInfo + SHA-256(헤더.페이로드))になるはずです。理解のために標準ライブラリで直接記述してみました(本番コードでは検証済みのライブラリを使用してください)。
def rs256_ok(token, jwk):
h64, p64, s64 = token.split(".")
n = int.from_bytes(ub64(jwk["n"]), "big"); e = int.from_bytes(ub64(jwk["e"]), "big")
k = (n.bit_length() + 7) // 8
em = pow(int.from_bytes(ub64(s64), "big"), e, n).to_bytes(k, "big")
di = bytes.fromhex("3031300d060960864801650304020105000420") + hashlib.sha256(f"{h64}.{p64}".encode()).digest()
return em == b"\x00\x01" + b"\xff" * (k - 3 - len(di)) + b"\x00" + di
実測:実習用IdPが発行したアクセストークン、IDトークン、ログアウトトークンはいずれもJWKSの2048ビットRSAキーでこの関数を通過し、ペイロードの1ビットを変更すると失敗しました。
署名が合っていても終わりではない
署名は「IdPが作成した」ことしか証明しません。「自分宛ての、今、このログインによって届いたトークンであるか」はクレームで個別に確認する必要があります。OIDC Core §3.1.3.7ではIDトークンの検証を13段階で定めており、核心は図の4〜7番です。 iss は文字列まで一致している必要があり、 aud には自分のclient_idが含まれており、 exp を過ぎておらず、 nonce が自分が送信した値である必要があります。JWTベストプラクティス(RFC 8725)では、ライブラリが受け入れる アルゴリズムのリストを呼び出し側が指定し (§3.1)、 aud を必ず検証し(§3.9)、 alg: none は「明示的に要求しない限り受け入れない」(SHOULD NOT、§3.2)よう求めています。
実測したトークンから2つの落とし穴が見つかりました。
- Keycloakアクセストークンの
audはデフォルトでaccountでした。 これは自分のAPIの名前ではありません。API側でaudを正しく検証するには、IdPにオーディエンス(audience)マッパーを追加してAPI名を含める必要があります。面倒だからとAPI側でaudの検証を無効にすると、同じIdPが別のアプリに発行したトークンで自分のAPIを呼び出せるようになってしまいます。 - JWTアクセストークンの標準(RFC 9068)ではヘッダーの
typをat+jwtにするよう規定していますが、実測したトークンはJWTでした(クレームのtypはBearer)。RFC 9068 §4に従ってtypを厳格にチェックするリソースサーバーであれば、このトークンを拒否します。どちらに合わせるか連携前に決めておく必要があります。
exp には時計の誤差の許容範囲(leeway)が適用されます。RFC 7519 §4.1.4では「通常数分を超えない小さな余裕」が許可されています。実測したIDトークンを 有効期限の1秒後の時計で検証した場合、余裕を持たせないと exp で失敗し、60秒の余裕を持たせると通過しました。Spring Securityリソースサーバーのデフォルトの余裕は60秒(公式ドキュメント)であり、セクション6のゲートウェイでも実測値は約60秒でした。
クラスターIdPの公開メタデータ
TT LabクラスターのKeycloakからDiscoveryドキュメントとJWKSを読み取りました(2026-10-26)。どちらも認証なしでアクセス可能な公開情報であり、以下の値はKeycloakがレルムごとにデフォルトで提供しているリストです。
| 項目 | 値 |
|---|---|
| Discoveryドキュメントのキー数 | 57 |
| エンドポイント | authorization·token·userinfo·end_session·jwks·introspection·revocation·device_authorization·PAR·CIBA·registration |
grant_types_supported |
10個:authorization_code·refresh_token·client_credentials·device_code·token-exchange·jwt-bearer·uma-ticket·ciba、 およびimplicit·password |
code_challenge_methods_supported |
plain, S256 |
id_token_signing_alg_values_supported |
13個(RS·PS·ES·HS系およびEdDSA) |
| ログアウト | backchannel·frontchannelの両方をサポート、セッション(sid)を含む |
| JWKS | キー2個:署名用RS256(2048ビット)、暗号化用RSA-OAEP |
ここで注目すべき点は、 Discoveryドキュメントはサーバーが「実行できること」のリストであり、クライアントに「許可されているもの」ではないということです。implicit·password·plainがリストに存在していても、クライアントごとに無効化することが可能であり、また無効化すべきです。クライアントごとに標準フロー(code)のみを有効にし、implicitやダイレクトアクセス(password)フローは無効にするのが基本姿勢です。
5. エージェント(SDK)方式:アプリ内のライブラリが行うこと
アプリ内のOIDCライブラリは、上記のセクション3〜4の内容をすべて代行します。リストアップすると、想像以上に長くなります。
- 開始時にDiscoveryドキュメントを読み込み、JWKSを取得してキャッシュする。未知の
kidが届いた場合は(回数制限を設けた上で)再取得する。 - セッションなしで保護されたパスにアクセスがあった場合、state·nonce·verifier を作成して サーバー側のセッションに保存し、IdPへリダイレクトする。
- コールバックで state を照合し、code と verifier でトークンを取得し、IDトークンを検証する。
- アプリセッションクッキー(HttpOnly·Secure·SameSite)を発行する。トークンはサーバー側に保持する。
- アクセストークンの有効期限が切れる前にリフレッシュトークンで更新する。失敗した場合は再ログインさせる。
- ログアウト:アプリセッションを削除し、IdPの
end_session_endpointへ送信する(RP-Initiated Logout)。IdPが送信する バックチャネルログアウト リクエストを受け取るエンドポイントも公開する(セクション7)。 - APIを提供する側(リソースサーバー)の場合、受信したBearerトークンの署名·iss·aud·expを検証する。
ローカルの実測において、Pythonでこのプロセスを模倣したコードは約150行でしたが、それですらセッション保存、キーローテーション、並行処理は除外されています。自作せずに検証済みのライブラリを使用する理由はここにあります。
どのようなライブラリがあるか、そしてKeycloakアダプターの退場
かつてはKeycloakが言語別の アダプターを直接提供していました。現在はそのほとんどが姿を消しています。公式ブログをたどると、その経緯は以下の通りです。
| 日付 | 日 |
|---|---|
| 2022-02 | OIDC Javaアダプター(WildFly·Spring Bootなど)およびNode.jsアダプターの非推奨化予告。代替として各プラットフォームの標準OIDCサポート(WildFly標準OIDC、Spring Security、Quarkus OIDC)を推奨 |
| 2023-03 | サポート期間延長の告知。代替例としてSpring Security、Elytron OIDC、Quarkus OIDC |
| 2023-11, 23.0.0 | WildFly·EAP用OIDCアダプターの削除 |
| 2024-06, 25.0.0 | Tomcat·サーブレットフィルター·Spring·Spring Boot アダプターなどの削除 |
| 2025-02 | ブラウザー向けの keycloak-js は維持され、別リポジトリ・別リリースサイクルに分離 |
この流れの意味は明確です。「 IdP企業が言語ごとにSDKを維持する必要がないほど、OIDCが標準化された」ということです。JavaであればSpring Securityが代表格です。
# 로그인(oauth2Login) — 앱이 OIDC 클라이언트가 된다
spring.security.oauth2.client.provider.idp.issuer-uri: https://<IdP>/realms/<렐름>
spring.security.oauth2.client.registration.idp: { client-id: <앱>, client-secret: <비밀>, scope: [openid, profile] }
# 토큰 검사(oauth2ResourceServer) — API 가 Bearer 를 검증한다
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://<IdP>/realms/<렐름>
issuer-uri により、1つの設定でDiscoveryドキュメントからJWKSアドレスを検索し(初回リクエスト時)、時計の許容範囲のデフォルトは60秒となります(Spring Securityドキュメント)。Spring Boot 4.xではスターター名が spring-boot-starter-security-oauth2-client·…-resource-server に変更され、古い名前は廃止予定となります。ログインしたユーザーの認可されたクライアント(トークン)はデフォルトで メモリ上の OAuth2AuthorizedClientService に保存されます。セッションに保存されるのは匿名リクエストのみです。アプリを複数のインスタンスにスケールアウトする場合、このストレージを共有ストレージに変更する必要があります。
Argo CD、Grafana、Hargo、Giteaなどの製品には 内部にOIDCクライアントが組み込まれており 、設定にIdPのアドレスとクライアントシークレットを入力するだけで動作します。これもエージェント方式の一種です。ライブラリを自ら組み込んだか、製品にあらかじめ含まれていたかの違いにすぎません。
6. ゲートウェイ方式:アプリの手前で代行して検証する
アプリを修正できない場合(他人が作成したダッシュボード)や、言語がバラバラなサービスが多数ある場合は、入口で一括処理する方が得策です。ゲートウェイの役割は大きく分けて2つあります。
- ログインまで代行するプロキシ:oauth2-proxyが代表例です。ブラウザーをIdPへリダイレクトし、コールバックを受け取り、結果を クッキーセッション(デフォルトはクッキーに格納され、Redisに変更可能)として保持します。Nginxの
auth_requestのように「このリクエストを通してもよいか」だけを問い合わせる用途で/oauth2/authを使用する場合は、202または401のみが返されます。通過したリクエストにはX-Auth-Request-User·-Email·-GroupsなどのヘッダーやAuthorization: Bearer <토큰>を付与して転送します(公式ドキュメント)。 - トークン検証のみを行うプロキシ:Envoyの
jwt_authnフィルター、agentgatewayのjwtAuthのように、すでにBearerトークンを保持しているクライアント(他のサービス、CLI、エージェント)からのリクエストを検証します。EnvoyはリモートJWKSをデフォルトで10分間キャッシュします(公式ドキュメント)。
これら2つのアプローチを組み合わせて使用する構成もよく見られます。ログインプロキシがPKCEでログインさせ、IdPのグループクレームでアクセス者を絞り込んだ後にトークンを Authorization ヘッダーとして付与し、その背後にあるサービスメッシュが同じJWTを 再度検証するような形です。この際の原則は1つです。「 プロキシのクッキー更新周期はIdPのアクセストークンの寿命より短くし、クッキーの有効期限はIdPのSSOセッションの最大長を超えないように する」ことです。これにより、背後でJWTを検証する側が有効期限切れのトークンを確認する前にプロキシが新しいトークンを取得でき、IdPでセッションが終了したユーザーがクッキーだけで継続して侵入するのを防ぐことができます。oauth2-proxyの公式設定表では、クッキー更新(ズレ)(cookie_refresh)のデフォルト値は空で、0の場合は無効になり、クッキーの有効期限(cookie_expire)のデフォルト値は168時間(7日)です。トークンを背後に転送する構成にする場合、双方はIdPの設定に合わせて手動で決定する必要があります。
実測:ゲートウェイはどのようなトークンを拒否するか
実習用のRSAキーでJWTを作成し、 jwtAuth(strict、iss·aud指定、JWKSファイル)を有効にしたagentgatewayをローカルに立ち上げ、リクエストを1件ずつ送信しました(2026-10-26)。
| 送信したトークン | 結果 | ゲートウェイが出力した理由 |
|---|---|---|
| 正常 | 200 | — |
| トークンなし | 401 | no bearer token found |
| audが異なるアプリ | 401 | InvalidAudience |
| issが異なる | 401 | InvalidIssuer |
| ペイロードの1文字を変更 | 401 | InvalidSignature |
alg: none、署名なし |
401 | ヘッダーの解析失敗(不明な alg) |
alg: HS256、公開鍵PEMをHMACシークレットに指定(アルゴリズムの混同) |
401 | InvalidAlgorithm |
不明な kid |
401 | unknown key |
nbf は2分後 |
401 | ImmatureSignature |
| 有効期限が10秒経過 | 200 | — |
最後の行が興味深い結果です。有効期限を10・30・55・58・62・65・90・120秒前に変更してみたところ、 58秒までは通過し、62秒からは拒否されました。また、 nbf も55秒後までは通過し、65秒後からは拒否されました。これは、前後に約60秒の時計の許容範囲(leeway)がデフォルトで組み込まれていることを意味します(ドキュメントでは未確認、実測値に基づく)。有効期限を「その秒数ぴったり」と信頼して設計してはならない理由はここにあります。
コストも計測しました。同じバックエンドを直接呼び出す場合、認証なしのゲートウェイを介する場合、JWT検証ありのゲートウェイを介する場合の3パターンで、それぞれ400回ずつ交互にリクエストを送信しました(50回のウォームアップ後)。
| パス | p50 | p90 | p99 |
|---|---|---|---|
| バックエンド直接 | 0.60ms | 0.73ms | 1.19ms |
| ゲートウェイ(認証なし) | 2.51ms | 3.05ms | 4.82ms |
| ゲートウェイ(JWT検証) | 2.59ms | 3.17ms | 5.21ms |
ゲートウェイをもう1つ経由することによるオーバーヘッド(約1.9ms、ノートPC上のコンテナネットワークの往復を含む)に比べ、 RS256検証そのものは約0.08ms でした。検証コストを理由にゲートウェイの導入をためらう必要はありません。
2つの方式のメリット・デメリット
| エージェント(SDK) | ゲートウェイ | |
|---|---|---|
| アプリコード | 変更が必要(言語やフレームワークごと) | ほとんどそのまま |
| 権限の判定 | ビジネスロジック内で詳細に制御可能(所有者のみなど) | パス・メソッド・ヘッダー単位で制御可能 |
| アプリが予期せぬパスで露出した場合 | それでもブロックされる | そのままアクセスが通ってしまう アプリへの他の経路(サービス直接露出すべき、ポートフォワード)をネットワークポリシーでブロックする必要がある |
| ヘッダー信頼 | 該当なし | アプリはゲートウェイが付与したヘッダーのみを信頼すべきであり、外部から同名のヘッダーが流入した場合はゲートウェイが削除すべきである |
| ログアウト反映 | バックチャンネルを受信したら直ちに | クッキーやトークンの寿命まで残る可能性がある |
| 運用 | アプリごとにライブラリのバージョン管理 | 一箇所でバージョン・設定を管理、ただしその場所が単一障害点になる |
現実には組み合わせて使用します。ブラウザログインはゲートウェイ(またはBFF)が、APIの細やかな権限はアプリ内のリソースサーバーライブラリが担当する形です。
7. ログアウトとセッション同期
SSOのログアウトが難しい理由は、 セッションが3重になっているためです。IdPのSSOセッション(IdPクッキー)、アプリのセッション(アプリクッキー)、そしてすでに発行されて各自が保持しているトークンです。1つだけ削除しても残りが残存します。
| 方式 | 誰から誰へ | 特徴 |
|---|---|---|
| RP-Initiated Logout | アプリ → IdP の end_session_endpoint |
id_token_hint 推奨、 post_logout_redirect_uri は登録された値と正確に一致している必要がある |
| Front-Channel Logout | IdPがログアウト画面に各アプリのアドレスをiframeで埋め込む | ブラウザがサードパーティクッキーをブロックしていると失敗しやすい |
| Back-Channel Logout | IdP → アプリサーバーへ直接POST | ブラウザとは無関係。 logout_token(JWT)に events と sub または sid, nonce は含まれていてはならない |
実測: ログアウトしたのにアクセストークンがさらに5分間生存する
テスト用IdPのクライアントにバックチャンネルログアウトのアドレスを登録し(ノートPCで受信する小さなHTTPサーバー)、新しくログインした後にリフレッシュトークンでログアウトをリクエストしました。
| 観測 | 値 |
|---|---|
| ログアウト要求 | 204 |
| バックチャンネル通知の到着 | 66ms後, application/x-www-form-urlencoded の logout_token |
| logout_token ヘッダー | alg: RS256, typ: logout+jwt(仕様の推奨値) |
| logout_token クレーム | iss·aud·iat·exp·jti·events·sid·sub·typ. nonce なし。寿命120秒 |
events |
{"http://schemas.openid.net/event/backchannel-logout": {}} |
sid |
ログイン時に取得したIDトークンの sid と一致 → アプリはどのセッションを削除すべきかを把握する |
| ログアウト後のリフレッシュ | 400 「Session not active」 |
| ログアウト後のintrospection (RFC 7662) | active: false |
| ログアウト後の同じアクセストークンの署名・有効期限検証 | パス、残り寿命299秒 |
最後の行が核心です。JWTアクセストークンは 自己完結型の証明書であるため、署名のみを確認するAPIやゲートウェイはIdPがセッションを削除したことを認識できません。ログアウト後もトークンの寿命が切れるまで(ここでは5分間)APIを呼び出すことができます。対策は3つのいずれかです。アクセストークンの寿命を短くするか、重要なAPIはintrospectionでIdPに問い合わせるか(リクエストごとのラウンドトリップコストが発生)、バックチャンネル通知を受信してその sid をブロックリストに登録します。
リフレッシュトークンにも同様の悩みがあります。テスト用レルムのデフォルト値(ローテーション無効)では、 すでに一度使用したリフレッシュトークンを再使用しても200 でした。レルムの設定 revokeRefreshToken を有効にすると(ローテーション)、古いリフレッシュトークンは400 「Maximum allowed refresh token reuse exceeded」として拒否され、 その直後に新しいリフレッシュトークンまで「Session doesn't have required client」としてブロックされました。 再利用を盗難の兆候と見なし、そのセッション全体を切断する動作です。RFC 9700 §2.2.2では、パブリッククライアントのリフレッシュトークンはローテーションするか、送信者にバインドするよう求めています。
8. よくある落とし穴
① redirect_uriの不一致。 IdPは登録されたアドレスと 文字列として厳密に 比較します(RFC 9700 §2.1、ネイティブアプリのlocalhostポートのみ例外)。実測において、末尾の / が1文字違うだけで400画面が表示されました。プロキシ配下でアプリが自身のアドレスを http:// として認識している場合(TLSを前段で終端している場合)、またはポートやパスのプレフィックスが異なると同様のエラーが発生します。ワイルドカードのredirect URIは攻撃者がcodeを自身のパスに取得させる原因となるため、使用しません。
② issの文字列不一致。 トークンの iss とアプリ設定のissuerが http/https、ホスト名、末尾の / まで一致している必要があります。IdPを内部アドレスで呼び出し、ブラウザは公開アドレス経由でアクセスする構成で特に頻繁に発生します。Keycloakはリクエストを受け付けたホスト名で iss を生成できるため、固定された公開ホスト名を設定しておく方が安全です。
③ クロックズキュー(時刻のずれ)。 IdP、ゲートウェイ、アプリサーバーの時刻が異なると、取得したばかりのトークンが「まだ有効ではありません」(nbf・iat)や「有効期限切れ」になってしまいます。実測されたデフォルトの許容誤差はゲートウェイ、Springともに約60秒でした。許容誤差を増やす前に、まずNTPを同期させます。
④ トークンをブラウザストレージに保存する。 SPAがアクセストークンやリフレッシュトークンを localStorage に保存すると、1度のXSSですべて流出します。2026年8月にRFC 10017(BCP 212)となった「ブラウザベースアプリケーション向けOAuth 2.0」では、パターンをセキュリティの順にBFF(Backend for Frontend)→トークン仲介バックエンド→ブラウザ単体の順に並べ、ブラウザ単体は前述したすべての攻撃に対して脆弱であると記載しています。BFFはシークレットを持つサーバー側のクライアントとしてトークンをサーバーに保持し、ブラウザにはHttpOnlyセッションクッキーのみを渡します。つまり、 第5節のSDK方式をサーバー側で行うことになります。
⑤ audを確認しない。 第4節のKeycloakのデフォルト aud: account のように、トークンの対象が自身のAPIではないにもかかわらず受け入れてしまうと、同じIdP内の他のアプリのトークンで自身のAPIを呼び出せてしまいます。
⑥ Secureクッキーと平文http。 IdPのログインクッキーには Secure が付与されています(実測)。ブラウザは localhost·127.0.0.1 を安全な送信元と見なして送信しますが、LAN内の平文httpアドレスでは送信されないため、「ログインしたのに再度ログイン画面が表示される」現象が繰り返されます。実測中にPythonのクッキーリポジトリが同様の理由でクッキーを送信せず、400エラーを受け取りました。
⑦ JWKSキーのローテーション。 IdPがキーを更新すると、新しい kid のトークンが送られてきます。キャッシュを長く保持している検証器(Envoyのデフォルトは10分)はその間拒否してしまうため、IdPは新しいキーを事前に公開してしばらく経ってから署名に使用し、検証器側には不明な kid からJWKSを再取得させます。
⑧ ゲートウェイのバイパスとヘッダー偽造。 ゲートウェイ方式においてアプリが X-Auth-Request-User を信頼する場合、外部からそのヘッダーを直接付与してアプリを直接呼び出す経路が存在してはなりません。
9. どちらを選ぶべきか
| 状況 | 推奨される選択肢 |
|---|---|
| サードパーティ製のWeb UI(ダッシュボード・ノートブック)の前段にログインを配置したい場合 | ゲートウェイ(oauth2-proxy) |
| 製品にOIDCがすでに組み込まれている場合(Argo CD・Grafanaなど) | 製品内蔵(=SDK方式) |
| 自社開発のWebアプリ、細やかな権限管理が必要な場合 | サーバー側SDK(BFF)、Spring Security、各言語の認証ライブラリ |
| サービス間、CLI、エージェントがBearerトークンで呼び出すAPI | ゲートウェイでのJWT検証 + アプリ内のリソースサーバー検証(2重チェック) |
| 純粋なSPA、サーバーレス | 可能であればBFFを1つ配置する。難しい場合はPKCE + 短命トークン + ローテーション |
10. まとめ
- SSO連携は「同じOIDC処理を誰が実行するか」の問題です。アプリ内のライブラリ(エージェント・SDK)であれ、アプリ前段のプロキシ(ゲートウェイ)であれ、行うべきことはリダイレクト、PKCE、code交換、署名・クレーム検証、セッション管理、更新、ログアウトであり、共通しています。
- 署名は発行元を証明するだけです。
iss·aud·exp·nonceは個別に確認する必要があり、実測したKeycloakのアクセストークンはデフォルトではaudがAPI名ではありませんでした。 - ログアウトは3重構造になっています。実測ではバックチャンネル通知がわずか66msで届いたものの、署名のみを確認する側ではアクセストークンが残りの5分間そのまま有効でした。
- Keycloakの言語別アダプターはバージョン23・25で廃止され、現在は各プラットフォームの標準的なOIDCサポートを使用します。
Titiの一言。 通行証(トークン)にスタンプ(署名)が押してあっても、「うちの卓球場のものか」(aud)、「今日の日付か」(exp)は個別に確認しなければなりません。また、退場させても手に持っている通行証は有効期限まで有効なため、有効期限を短く設定するのが賢明です。
参考
- RFC 6749, OAuth 2.0. §4.1.2 (code再利用時の発行済みトークン破棄推奨). https://www.rfc-editor.org/rfc/rfc6749.html
- RFC 7636, PKCE. §4.1·§4.2·付録B. https://www.rfc-editor.org/rfc/rfc7636.html
- RFC 9700, OAuth 2.0 Security Best Current Practice (2025-01). §2.1·§2.1.1·§2.1.2·§2.2.2·§2.4. https://www.rfc-editor.org/rfc/rfc9700.html
- RFC 10017, OAuth 2.0 for Browser-Based Applications (BCP 212, 2026-08). §5·§6. https://www.rfc-editor.org/rfc/rfc10017.html
- RFC 7519(JWT) §4.1.4·§4.1.5, RFC 7515(JWS) §3.1·§7.1, RFC 7517(JWK) §4.5·§5, RFC 8725(JWT BCP) §3.1·§3.2·§3.9, RFC 7662(introspection) §2.1·§2.2, RFC 9068(JWTアクセストークン) §2.1·§4. https://www.rfc-editor.org/
- OpenID Connect Core 1.0 §2·§3.1.2.1·§3.1.3.7 https://openid.net/specs/openid-connect-core-1_0.html · Discovery 1.0 §4 https://openid.net/specs/openid-connect-discovery-1_0.html · Back-Channel Logout 1.0 §2.4·§2.5 https://openid.net/specs/openid-connect-backchannel-1_0.html · Front-Channel Logout https://openid.net/specs/openid-connect-frontchannel-1_0.html · RP-Initiated Logout https://openid.net/specs/openid-connect-rpinitiated-1_0.html
- OASIS, SAML V2.0 Technical Overview §4.6·§5.1.2. https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
- Keycloak ブログ: アダプターの非推奨化(2022-02) https://www.keycloak.org/2022/02/adapter-deprecation · 更新(2023-03) https://www.keycloak.org/2023/03/adapter-deprecation-update · 23.0.0 https://www.keycloak.org/2023/11/keycloak-2300-released · 25.0.0 https://www.keycloak.org/2024/06/keycloak-2500-released.html · keycloak-js の分離(2025-02) https://www.keycloak.org/2025/02/keycloak-js-2620-released
- Spring Security リソースサーバー JWT (クロック許容範囲 60秒) https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html · OAuth2 クライアントコア https://docs.spring.io/spring-security/reference/servlet/oauth2/client/core.html · Spring Boot スターター https://docs.spring.io/spring-boot/reference/using/build-systems.html
- oauth2-proxy: エンドポイント https://oauth2-proxy.github.io/oauth2-proxy/features/endpoints · 設定 (cookie_refresh・cookie_expire のデフォルト値) https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview
- Envoy jwt_authn (cache_duration デフォルト 10分) https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/jwt_authn/v3/config.proto
- すべての原文は 2026年10月26日にアクセスして確認しました。