はじめに
2026 年現在、新しく作られるほぼすべてのシステムのログインは OpenID Connect(OIDC)に収斂しました。ソーシャルログインも、社内 SSO も、モバイルアプリも、CLI ツールのログインでさえ、その底には OIDC Authorization Code Flow が流れています。それにもかかわらず、OIDC を「ライブラリの設定数行」としてしか知らないまま運用し、トークン検証の欠落や nonce の未使用といった穴から事故が起きる事例が後を絶ちません。
本記事は OIDC をプロトコルレベルで解剖します。HTTP リクエスト/レスポンスを直接読み、3 種類のトークンの役割を区別し、Discovery と JWKS の動作を理解し、最後に実務用のトークン検証チェックリストとしてまとめます。OAuth 2.1 ドラフトが事実上の基準となった時点ですので、すべての説明は Code + PKCE を前提とします。
OIDC のレイヤー構造 — OAuth 2.0 の上に積んだもの
OIDC は OAuth 2.0 を置き換えません。**OAuth 2.0 のフローをそのまま使いながら、その上に認証レイヤーを追加**します。
+---------------------------------------------------------------+
| OIDC が追加したもの |
| - ID Token(署名付き JWT、認証結果の標準フォーマット) |
| - scope=openid と標準クレーム(sub, email, name...) |
| - Discovery (/.well-known/openid-configuration) |
| - JWKS(署名鍵の配布)、UserInfo エンドポイント |
| - nonce, max_age, prompt, acr など認証専用パラメータ |
| - RP-Initiated / Back-Channel Logout |
+---------------------------------------------------------------+
| OAuth 2.0 (RFC 6749) が提供するもの |
| - authorize / token エンドポイントと grant フロー |
| - access token, refresh token |
| - クライアント登録と redirect_uri の検証 |
+---------------------------------------------------------------+
| 基盤: HTTP + TLS, JWT (RFC 7519), JWS/JWK |
+---------------------------------------------------------------+
用語も OAuth の上に重ねて変わります。OAuth の client は OIDC では **RP(Relying Party)**、authorization server は **OP(OpenID Provider)**になります。
3 つのトークン — 役割を絶対に混ぜないこと
| 項目 | ID Token | Access Token | Refresh Token |
| --- | --- | --- | --- |
| 受け手(audience) | RP(クライアント) | Resource Server(API) | OP の token エンドポイント |
| 目的 | 「ユーザーが認証された」ことの証明 | API 呼び出しの権限 | 新しいトークンの再発行 |
| フォーマット | 必ず JWT | 自由(JWT または opaque) | 自由(通常 opaque) |
| 検証の主体 | RP 自身が署名/クレームを検証 | API サーバーが検証 | OP のみが解釈 |
| 寿命 | 短く(分単位) | 短く(5〜15 分推奨) | 長く(ローテーション必須) |
| 送ってはいけない先 | API に送らない | ログインの証拠に使わない | RP の外へ絶対に出さない |
最も多い 2 つの誤用:
1. **ID Token を API に Bearer として送ること** — ID Token の aud は client_id であって API ではありません。API がこれを受け入れるなら audience 検証を放棄したことになります。
2. **Access Token でログイン処理をすること** — access token には「誰が認証したか」についての受け手に紐づく保証がありません。ログインの判断は ID Token だけで行います。
ID Token の中身を覗く
ID Token は 3 つの部分(header.payload.signature)からなる JWS です。
{
"alg": "RS256",
"typ": "JWT",
"kid": "rsa-key-2026-05"
}
{
"iss": "https://idp.corp.com/realms/prod",
"sub": "f9a8b7c6-1234-5678-90ab-cdef12345678",
"aud": "web-dashboard",
"exp": 1781258400,
"iat": 1781258100,
"auth_time": 1781258095,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"azp": "web-dashboard",
"email": "yj.kim@corp.com",
"email_verified": true,
"name": "Youngju Kim",
"preferred_username": "yj.kim"
}
- iss: 発行者。Discovery ドキュメントの issuer と**文字列として正確に**一致しなければなりません。
- sub: ユーザーの不変の識別子。**アカウントの突合は email ではなく iss+sub の組み合わせ**で行うべきです。メールアドレスは変わり、再利用され、未検証の可能性もあります。
- aud / azp: このトークンの受け手(自分の client_id)。他アプリのトークンを拒否する根拠です。
- exp / iat / auth_time: 失効、発行、実際の認証時刻。auth_time は max_age の要求とともに再認証ポリシーに使われます。
- nonce: 認証リクエスト時に送った値のエコー。リプレイ防御の核心です。
Authorization Code Flow + PKCE — HTTP レベルの解剖
全体のシーケンスは次のとおりです。
[ブラウザ] [RP サーバー] [OP]
| | |
|-- 1. GET /login ----->| |
| | 2. state, nonce, |
| | code_verifier 生成 |
|<- 3. 302 OP /authorize へリダイレクト --------|
|-- 4. GET /authorize ------------------------->|
|<- 5. ログイン UI(SSO セッションがあれば省略)|
|-- 6. 認証(passkey/MFA) -------------------->|
|<- 7. 302 redirect_uri?code=...&state=... -----|
|-- 8. GET /callback?code=...&state=... ->| |
| | 9. state を照合 |
| |-- 10. POST /token --->|
| | (code+verifier) |
| |<- 11. トークン応答 ---|
| | 12. id_token 検証、 |
| | nonce 照合 |
|<- 13. セッションクッキー --| |
ステップ 1: 認証リクエストの作成
RP はリクエスト前に 3 つのランダム値を作ります。
state: CSRF 防御用(セッションに保存)
state=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
nonce: ID Token のリプレイ防御用(セッションに保存)
nonce=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
PKCE: code_verifier とその SHA-256 ハッシュである code_challenge
code_verifier=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-128)
code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -sha256 -binary | openssl base64 -A \
| tr '+/' '-_' | tr -d '=')
そしてブラウザを authorization エンドポイントへ送ります。
GET /realms/prod/protocol/openid-connect/auth
?response_type=code
&client_id=web-dashboard
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: idp.corp.com
ステップ 2: コールバックと code の受信
認証が終わると OP はブラウザを送り返します。
HTTP/1.1 302 Found
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&iss=https%3A%2F%2Fidp.corp.com%2Frealms%2Fprod
RP が直ちにやるべきこと: **セッションの state と戻ってきた state が一致するかを比較**します。不一致ならその場で中断します(CSRF の試み)。iss パラメータ(RFC 9207)があれば、期待した発行者かどうかも確認して mix-up 攻撃を遮断します。
ステップ 3: code をトークンに交換(back-channel)
POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.corp.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic d2ViLWRhc2hib2FyZDpzM2NyM3Q=
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
OP は code_verifier を SHA-256 でハッシュし、最初に受け取った code_challenge と比較します。コードが窃取されても verifier なしでは交換が不可能 — これが PKCE(RFC 7636)です。
レスポンス:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzUxMiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email"
}
RP は id_token を検証し(下のチェックリスト)、payload の nonce がセッションに保存した値と一致することを確認した後に、はじめてログイン処理を行います。
confidential vs public クライアント
| 区分 | confidential | public |
| --- | --- | --- |
| 例 | サーバーレンダリング Web アプリ、BFF | SPA、モバイル、CLI |
| client_secret | 保有(または private_key_jwt) | なし |
| token リクエストの認証 | Basic または mTLS/JWT | client_id のみ |
| PKCE | OAuth 2.1 ではそれでも必須 | 必須(唯一の防御線) |
OAuth 2.1 基準では PKCE はクライアントの種類に関係なくすべてに適用します。SPA であればトークンをブラウザに直接持たせず、BFF(Backend For Frontend)パターンで confidential クライアント化するのが 2026 年の推奨構成です。
Discovery — 設定をコードに埋め込まない方法
OP は自身のすべての設定を標準パスに公開します。
curl -s https://idp.corp.com/realms/prod/.well-known/openid-configuration
{
"issuer": "https://idp.corp.com/realms/prod",
"authorization_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/auth",
"token_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/token",
"userinfo_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/userinfo",
"jwks_uri": "https://idp.corp.com/realms/prod/protocol/openid-connect/certs",
"end_session_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/logout",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["RS256", "ES256", "EdDSA"],
"code_challenge_methods_supported": ["S256"]
}
RP ライブラリは issuer URL を 1 つ設定すれば、残りはここから読み込みます。重要な検証ルールが 1 つあります。**ドキュメント内の issuer 値が、ドキュメントを取得した URL の issuer 部分と一致しなければなりません。**この確認がなければ、悪意ある OP が他者の設定を装う攻撃が可能になります。
Keycloak 26.6 では EdDSA 署名もサポートされているため、新規構築であれば RS256 に加えて ES256/EdDSA の採用も検討に値します。
JWKS と鍵ローテーション
jwks_uri は ID Token の署名検証に使う公開鍵の一覧(JWK Set)を提供します。
{
"keys": [
{
"kid": "rsa-key-2026-05",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZpt...",
"e": "AQAB"
},
{
"kid": "rsa-key-2026-06",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "zNf1a9pXkQpW3mLbV2c...",
"e": "AQAB"
}
]
}
検証の流れ: トークン header の kid → JWKS から同じ kid の鍵を選択 → 署名検証。鍵が 2 つ見えるのは正常です。**鍵ローテーション中は新しい鍵と古い鍵が共存**するからです。
鍵ローテーションを無停止にする運用ルール:
1. OP: 新しい鍵を先に JWKS へ公開し、十分な時間(キャッシュ TTL 以上)が経ってから新しい鍵で署名を開始し、古い鍵で署名されたトークンがすべて失効してから古い鍵を削除します。
2. RP: JWKS をキャッシュしつつ(毎リクエスト照会は禁止)、**未知の kid に遭遇したら一度だけ再取得**するロジックを入れます。ただし再取得に rate limit をかけて DoS を防ぎます。
3. kid のないトークン、JWKS にない kid、alg の不一致はすべて拒否します。
UserInfo、クレーム、スコープ
スコープ → クレームのマッピング
scope は「どのクレームの束が欲しいか」の単位です。
| scope | 解放される標準クレーム |
| --- | --- |
| openid | sub(OIDC リクエストであることの表明、必須) |
| profile | name, family_name, given_name, preferred_username, picture など |
| email | email, email_verified |
| address | address |
| phone | phone_number, phone_number_verified |
| offline_access | refresh token の発行要求 |
UserInfo エンドポイント
クレームは ID Token に載せて受け取るか、access token で UserInfo を呼び出して受け取れます。
curl -s https://idp.corp.com/realms/prod/protocol/openid-connect/userinfo \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
{
"sub": "f9a8b7c6-1234-5678-90ab-cdef12345678",
"name": "Youngju Kim",
"email": "yj.kim@corp.com",
"email_verified": true,
"groups": ["sso-admins", "developers"]
}
注意: **UserInfo 応答の sub が ID Token の sub と一致するかを必ず比較**してください。トークン差し替え攻撃をふるい落とす最後のセーフティネットです。ID Token を軽く保ちたければクレームを UserInfo へ後回しにし、ゲートウェイで一度に使いたければ ID Token に含める、というトレードオフを設計します。
state と nonce — 2 つの異なる盾
混同しやすい 2 つのパラメータの役割を明確に区別します。
| 項目 | state | nonce |
| --- | --- | --- |
| 防御対象 | CSRF(コールバックの偽造) | ID Token のリプレイ/注入 |
| 誰が作るか | RP | RP |
| どこから戻るか | コールバックのクエリパラメータ | ID Token のクレーム |
| いつ検証するか | コールバック受信の直後 | ID Token の検証時 |
| ないと何が起きるか | 攻撃者が自分の code を被害者セッションに注入(セッション固定) | 窃取された ID Token の再利用 |
典型的な攻撃シナリオを 1 つ見てみましょう。state がなければ、攻撃者は自分のアカウントでログイン手続きを進め、コールバック URL(自分の code を含む)を抜き出して被害者にクリックさせます。被害者のブラウザがそのコールバックを踏むと、**被害者のセッションが攻撃者のアカウントでログイン**されます。以降、被害者が入力するデータは攻撃者のアカウントに蓄積されます。state の照合 1 行がこのすべてを防ぎます。
トークン検証チェックリスト(実務用)
ID Token 受信時(RP):
[ ] 1. token エンドポイントの TLS 応答から受け取ったか
(front-channel での受領は禁止)
[ ] 2. JWS header の alg が許可リスト(RS256/ES256/EdDSA)にあるか
- alg=none を拒否、対称鍵(HS256)の混用を拒否
[ ] 3. kid で JWKS から鍵を見つけて署名検証
[ ] 4. iss == Discovery の issuer(文字列の完全一致)
[ ] 5. aud に自分の client_id が含まれる、複数 audience なら azp == 自分の client_id
[ ] 6. exp 未経過、iat が妥当な範囲(clock skew 60〜120 秒許容)
[ ] 7. nonce == セッションに保存した値(使用後は破棄)
[ ] 8. 再認証を要求した場合は auth_time/acr を確認
[ ] 9. アカウント突合は iss+sub の組み合わせで(email 突合は禁止)
Access Token 受信時(Resource Server):
[ ] 1. 署名検証(JWT の場合)または introspection(opaque の場合)
[ ] 2. iss, exp の検証
[ ] 3. aud または resource indicator が「この API」かを検証
[ ] 4. scope が要求された操作を許可しているか確認
[ ] 5. 必要に応じて発行時刻ベースの失効ポリシーを適用
このうち 4 番(iss)と 5 番(aud)を省略した実装が現実には驚くほど多いのです。「署名さえ有効なら通過」は、**どの OP のどのトークンでも通過**という意味です。
ログアウト — OIDC の難しい部分
OIDC は 3 つのログアウトメカニズムを標準化しました。
- **RP-Initiated Logout**: RP が end_session_endpoint へ送り、OP セッションを終了。
- **Front-Channel Logout**: OP が各 RP のログアウト URL を iframe で読み込み。サードパーティクッキーのブロックで信頼性が低下し、廃れつつあります。
- **Back-Channel Logout**: OP が各 RP のバックエンドへ logout token(JWT)を直接 POST。2026 年時点の推奨案です。
Back-channel logout を受けるには、RP が「sid(セッション ID)→ アプリセッション」のマッピングを維持する必要があります。ステートレスな JWT だけでセッションを運用するとこの要求を満たせないという点が、「アプリセッションはサーバー側セッション + IdP トークンはバックエンドで保管」という BFF 構成を推すもう 1 つの理由です。
OAuth 2.1 時代の OIDC — 2026 年の整合性まとめ
OAuth 2.1 ドラフトの制約を OIDC 実装に当てはめると次のようになります。
1. **Implicit/Hybrid で id_token を front-channel で受け取っていたパターンの廃止** — response_type=code への一本化。form_post が必要な特殊ケースでない限り、fragment でトークンを受け取りません。
2. **PKCE の全面適用** — OIDC の nonce があっても PKCE は別途必要です。nonce は ID Token の注入を、PKCE は code の窃取を防ぎます。防御対象が異なります。
3. **refresh token のローテーション** — public クライアントはローテーション + 再利用検知を有効にします。再利用が検知されたらトークンファミリー全体を失効させます。
4. **redirect_uri の完全一致** — ワイルドカード、部分一致はすべて禁止。
5. **Bearer トークンのクエリ文字列での受け渡し禁止** — ヘッダーまたは POST body のみ。
さらに高セキュリティのドメインであれば FAPI 2.0 Security Profile(Keycloak 26.6 で Final 対応)を、シークレット管理の強化が必要なら client_secret の代わりに private_key_jwt や mTLS クライアント認証を検討します。JWT Authorization Grant や federated client authentication といった Keycloak 26.6 の新機能は、サービス間の信頼連携シナリオでパスワードレスのクライアント認証を可能にします。
トラブルシューティングノート
| 症状 | よくある原因 | 確認方法 |
| --- | --- | --- |
| invalid_grant(token 交換失敗) | code の再利用、code の失効(数十秒)、redirect_uri 不一致、verifier 不一致 | OP のログで拒否理由を確認 |
| 署名検証の失敗が間欠的 | 鍵ローテーション直後の JWKS キャッシュが古い | 未知 kid の再取得ロジックの有無を点検 |
| ログインの無限ループ | コールバックでセッションクッキーが付かない(SameSite、ドメイン不一致) | Set-Cookie 属性とコールバックのドメインを確認 |
| 間欠的な exp/iat エラー | サーバー間のクロックスキュー | NTP の状態、skew 許容値の設定 |
| state 不一致エラーの多発 | マルチタブでのログイン、セッションストアの TTL が短い | state をタブごとに区別して保存 |
| モバイルだけログイン失敗 | アプリ内ブラウザ(WebView)のブロックポリシー | システムブラウザ(ASWebAuthenticationSession など)を使用 |
おわりに
OIDC を一文で要約すると「OAuth 2.0 の配管の上に、署名付き JWT(ID Token)とメタデータの自動発見(Discovery/JWKS)を載せて認証を標準化したプロトコル」です。実務の観点から核心を 3 つ残します。
- **3 つのトークンの役割を混ぜないでください。**ログインは ID Token、API は Access Token、更新は Refresh Token。それぞれ audience と検証の主体が異なります。
- **検証チェックリストをコードレビューの基準にしてください。**iss/aud/exp/nonce/署名のどれか 1 つでも欠けていれば、その実装は未完成です。
- **OAuth 2.1 の制約(Code + PKCE への一本化、refresh のローテーション、exact redirect_uri)はすでに現在の常識です。**ドラフトの確定を待つ理由はありません。
SAML が昨日の信頼を支えているとすれば、OIDC は今日と明日 — passkeys 統合、AI エージェントの委譲、FAPI 2.0 — が積み上がる場所です。プロトコルの底を理解しておけば、その上のどんな変化も怖くありません。
参考資料
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-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 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
- [RFC 7517 — JSON Web Key (JWK)](https://datatracker.ietf.org/doc/html/rfc7517)
- [RFC 9700 — Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)
- [RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/html/rfc9207)
- [OAuth 2.1 Draft (draft-ietf-oauth-v2-1)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)
- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
- [FAPI 2.0 Security Profile](https://openid.net/specs/fapi-2_0-security-profile.html)
- [RFC 8693 — OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)
현재 단락 (1/256)
2026 年現在、新しく作られるほぼすべてのシステムのログインは OpenID Connect(OIDC)に収斂しました。ソーシャルログインも、社内 SSO も、モバイルアプリも、CLI ツールのログ...