モバイルアプリがアイデンティティプロバイダーでユーザーをサインインさせ、アクセストークンをバックエンドに渡します。バックエンドはプロバイダーのプロフィールエンドポイントを呼び出してメールアドレスを取得し、セッションを作ります。テストはすべて通ります。しかしこの実装では、同じプロバイダーでサインインした別のアプリも、自分のアクセストークンでそのユーザーになりすましてあなたのアプリにログインできます。トークンがどのアプリに発行されたかを誰も確認していないからです。
OpenID Connect(OIDC)はこの穴をふさぎます。OAuth 2.0 に ID トークンを加えるもので、これは署名付きの JWT です。プロバイダーはその中で、ユーザーが誰か、トークンがどのアプリ向けか、ユーザーがいつサインインしたかを示します。
この記事では、PKCE 付きの認可コードフローをシーケンス図で描き、OIDC が加えるもの、つまり openid スコープ、nonce、ID トークンの検証、Discovery、鍵のローテーション、UserInfo、ログアウトを扱います。
OAuth 2.0 と OpenID Connect の違い
OAuth 2.0 はアクセスのための仕組みで、ユーザーに代わって API を呼び出すトークンを扱います。OpenID Connect は ID のための仕組みで、誰がサインインしたかを扱います。
| OAuth 2.0 | OpenID Connect | |
|---|---|---|
| 答える問い | このアプリはこの API を呼び出してよいか。 | ユーザーは誰で、いつサインインしたか。 |
| トークン | アクセストークン | ID トークン |
| トークンの宛先 | API | あなたのアプリ |
| トークンの形式 | 定義なし:不透明な文字列または JWT | 署名付き JWT |
| 要求の方法 | API が定義するスコープ | scope に openid を含める |
| 定義するもの | グラントとエンドポイント | クレーム、UserInfo、Discovery、ログアウト |
通信上では、OIDC のサインインは scope に openid を含む OAuth の認可リクエストであり、トークンレスポンスにはアクセストークンと並んで id_token が入ります。OAuth の部分は OAuth 2.0 フロー図で詳しく扱っています。
フローをステップごとに見る
OpenID Connect Core 1.0 では、アプリを Relying Party(RP)、認可サーバーを OpenID プロバイダー(OP)と呼びます。ブラウザーを通るもの、つまりフロントチャネルは、URL、履歴、ログに漏れる可能性があります。バックエンドからプロバイダーへの呼び出し、つまりバックチャネルは漏れません。番号は図と対応しています。
- 開始(1~3)。 ユーザーが Sign in をクリックします。アプリは新しい
state、nonce、PKCE のcode_verifierをセッションに保存し、ブラウザーをプロバイダーへリダイレクトします。 - 認証リクエスト(4)。 ブラウザーがリダイレクトに従います。
GET /authorize?response_type=code
&client_id=orders-web
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=7fJq2LbX9aQe
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: op.example.com
- ログインと同意(5)。 プロバイダーがユーザーを認証し(パスワード、パスキー、MFA、既存のセッションのいずれか)、同意を求めます。アプリが認証情報を目にすることはありません。
- コードの返却(6~7)。 プロバイダーは、有効期間の短い
codeと同じstateを付けて、登録済みのredirect_uriにリダイレクトします。アプリはこれをセッションの値と比較します。 - トークンリクエスト(8~9)。 バックエンドは、
code、redirect_uri、code_verifierをクライアント認証情報とともに POST します。プロバイダーは verifier を challenge と照合し、id_token、access_token、多くの場合はrefresh_tokenを返します。 - 検証(10)。 アプリは ID トークンを検証します。署名鍵を取得するのは、トークンが指定する鍵がキャッシュにないときだけです。
- UserInfo(11~12)、任意。 ID トークンに必要なクレームが含まれていない場合だけ使います。
- セッション(13)。 アプリが独自のセッションを作り、Cookie を設定します。
state、nonce、PKCE が守るもの
| 値 | 運ばれる場所 | 検証する側 | 防ぐもの |
|---|---|---|---|
state |
ステップ 4 と戻りのリダイレクト | あなたのアプリ | ユーザーが開始していないログインレスポンス(CSRF) |
nonce |
ステップ 4、その後 ID トークンの中 | あなたのアプリ | ID トークンのリプレイ、および機密クライアントでのコードインジェクション |
code_verifier |
ステップ 4 ではその SHA-256 ハッシュ、ステップ 8 では本体 | プロバイダー | 盗まれたコードの引き換え |
OAuth セキュリティのベストカレントプラクティスである RFC 9700 は、場合によっては一方が他方の代わりになることを認めています。ただ、どれもランダムな値を 1 つ足すだけなので、3 つとも送ってください。シングルページアプリやモバイルアプリなどのパブリッククライアントは、S256 を使う PKCE(RFC 7636)を必ず使う必要があります。
ID トークンの中身
ステップ 9 の ID トークンをデコードすると次のようになります。
{
"iss": "https://op.example.com",
"sub": "248289761001",
"aud": "orders-web",
"nonce": "n-0S6_WzA2Mj",
"iat": 1793736400,
"exp": 1793740000,
"auth_time": 1793736390
}
| クレーム | 意味 | アプリの扱い |
|---|---|---|
iss |
発行者、https の URL | Discovery で得た issuer と完全に一致する必要がある |
sub |
ユーザー ID。発行者の中で再割り当てされず、最大 255 文字の ASCII | iss と sub の組をアカウントキーにする |
aud |
オーディエンス、文字列または配列 | その中に自分の client_id が見つかる必要がある |
exp、iat |
有効期限と発行時刻、Unix 秒 | 期限切れや古すぎるトークンを拒否する |
nonce |
ステップ 4 で送った値 | セッションの値と一致する必要がある |
auth_time |
ユーザーが実際に認証した時刻 | max_age を送った場合に確認する |
acr、amr |
保証レベルと方式(pwd や mfa など) |
重要な操作の前に確認する |
Core は明確に述べています。ユーザーを識別できるのは iss と sub の組だけです。メールアドレスは変わりますし、再割り当てされることもあるためです。
ID トークンを検証する
Core の 3.1.3.7 節が規則を挙げています。
- アルゴリズムを固定します(既定は RS256)。
algヘッダーを信用せず、noneは決して受け入れません。 - 署名を検証します。
kidで指定された JWKS の鍵を使います。トークンはトークンエンドポイントから直接届くため、Core はコードフローのクライアントが代わりに TLS に頼ることを認めていますが、検証のコストは小さいものです。 issを確認します。 Discovery で得た発行者と 1 文字ずつ比較します。audを確認します。 自分のclient_idが含まれていることを確かめ、信頼しないオーディエンスは拒否します。expとiatを確認します。 数秒から数分程度の時計のずれは許容します。nonceを確認します。 セッションの値と比較し、その後で削除します。auth_timeとacrを確認します。 要求した場合に行います。
セッションを作るのはその後です。Node.js の jose ライブラリを使う例です。
import { createRemoteJWKSet, jwtVerify } from 'jose'
// issuer and jwks_uri come from the discovery document.
// The key set is cached; an unknown kid triggers a refetch
// (at most once every 30 seconds by default).
const JWKS = createRemoteJWKSet(new URL(oidc.jwks_uri))
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: oidc.issuer, // exact match
audience: CLIENT_ID, // must appear in aud
algorithms: ['RS256'], // never trust the header alone
clockTolerance: 60, // seconds of clock skew
maxTokenAge: 600, // rejects an old iat
})
if (payload.nonce !== session.nonce) throw new Error('nonce mismatch')
Discovery と鍵のローテーション
OpenID Connect Discovery 1.0 は、プロバイダーの設定を、発行者 URL に /.well-known/openid-configuration を付けた場所の 1 つの JSON ドキュメントにまとめます。次は Google のものを、このフローで使うフィールドに絞ったものです(執筆時点は 2026 年 11 月)。
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"id_token_signing_alg_values_supported": ["RS256"],
"code_challenge_methods_supported": ["plain", "S256"]
}
issuer は、取得元の発行者 URL とすべての ID トークンの iss に一致している必要があります。期待する発行者は自分で設定してください。検証していないトークンから取ってはいけません。
鍵は jwks_uri を通じてローテーションします。プロバイダーは新しい鍵を追加してそれで署名し、古い鍵もしばらく公開したままにします(Core の 10.1.1 節)。鍵セットはキャッシュし、Cache-Control に従い、見慣れない kid が来たときに再取得し、その再取得にはレート制限をかけてください。アクセストークンについて同じ仕組みを扱っているのが JWT 認証フロー図です。
UserInfo
UserInfo は、アクセストークンで呼び出すと、付与されたスコープ(profile、email、address、phone)に対応するクレームを返します。レスポンスには必ず sub が含まれ、これは ID トークンの sub と完全に一致する必要があります。一致しない場合はレスポンスを破棄します。
ログアウトの方式
サインアウトについては、最終版の OpenID 仕様が 3 つあります。
| 仕様 | 仕組み | 注意点 |
|---|---|---|
| RP-Initiated Logout 1.0 | アプリが id_token_hint を付けてプロバイダーの end_session_endpoint にリダイレクトする |
アプリは自分のセッションも終了する必要がある |
| Front-Channel Logout 1.0 | プロバイダーが各アプリのログアウト URL を iframe で読み込む | サードパーティ Cookie のブロックで動かなくなることがある |
| Back-Channel Logout 1.0 | プロバイダーが署名付きのログアウトトークンを各アプリに POST する | アプリはサーバー側でセッションを見つけて終了する必要がある |
Core は、ID トークンの exp がセッションの有効期間とは無関係であることにも触れています。
レビューでよく見る間違い
- アクセストークンを本人確認の証拠にする。 冒頭の場面です。ユーザーのサインインには、バックエンドがトークンエンドポイントから受け取って検証した ID トークンだけを使ってください。
audやnonceを省略する。 同じプロバイダーに登録されたすべてのアプリが、同じissを持つ正しく署名された ID トークンを受け取ります。トークンが自分宛てに発行されたと示すのはaudだけで、このログインに結び付けるのはnonceだけです。- インプリシットフロー。
response_type=id_token tokenはトークンを URL フラグメントに載せます。RFC 9700 は、認可レスポンスでアクセストークンを返すレスポンスタイプをクライアントは使うべきではないとしています。シングルページアプリでも、PKCE 付きのcodeを使ってください。 - API に送られる ID トークン。 API は自分向けに発行されたアクセストークンだけを受け入れるべきです。この確認が通常どこにあるかは、API ゲートウェイのアーキテクチャ図に示しています。
OpenID Connect フロー図
ダイアグラムの内容
- User
- Browser
- Relying party (your app)
- OpenID provider
- all checks must pass
- claims missing from the ID token
- ID token: for your app (aud = client_id)
- Access token: for UserInfo and APIs
- 1. Click Sign in
- 2. GET /login
- 3. 302 to the provider's /authorize
- 4. GET /authorize: scope=openid, state, nonce, code_challenge
- 5. Log in (password, passkey, MFA) and consent
- 6. 302 to redirect_uri with code, state
- 7. GET /callback?code&state
- 8. POST /token: code, code_verifier, client auth
- 9. id_token, access_token
- 10. GET jwks_uri (when kid is not cached)
- 11. GET /userinfo, Bearer access_token
- 12. Claims; sub must match the ID token
- 13. 302 to the app, Set-Cookie: session
図の読み方は次のとおりです。
- 4 つの参加者: ユーザー、ブラウザー、あなたのアプリ(Relying Party)、OpenID プロバイダーです。時間は上から下へ流れます。
- 色はチャネルを表します: 青はブラウザー経由、青緑はバックエンドからプロバイダー、紫はユーザーの操作です。破線の矢印はレスポンスとリダイレクトです。
- ステップ 3 と 4 は
scope=openid、state、nonce、PKCE の challenge を運び、ステップ 8 は verifier を運びます。 criticalフラグメントには、セッションができる前に通る必要がある鍵の取得と検証が入ります。optフラグメントは UserInfo の呼び出しです。- 右の列は、Discovery のフィールドとステップの対応、PKCE のパラメーター、デコードした ID トークン、どのトークンが誰向けかを示します。
OpenID Connect の図を分かりやすく描くには
- ブラウザーに専用のライフラインを与えます。 フロントチャネルとバックチャネルの分離が、このフローのセキュリティの要点です。
- 検証するパラメーターを矢印に書きます:
state、nonce、code_challenge、code_verifierです。 - 検証の中身を書き出します。 「トークンを検証」ではレビューできませんが、「署名、iss、aud、exp、iat、nonce」ならできます。
- どのトークンをどこへ渡すかを、オーディエンスとともに書きます。 OIDC のバグの多くは、トークンを誤った相手に送ることです。
- 1 つの図に 1 つのフロー。 ログイン、リフレッシュ、ログアウトは、仮の ID とトークンを使って別々に描きます。
記法についてはシーケンス図の例で扱っています。SAML SSO フロー図では、エンタープライズの顧客がいまも求める XML ベースのプロトコルを示しています。
Archboard で作る
- Google または GitHub でサインインし、ダイアグラムを作成します。
- シーケンスパックから、ユーザー用のアクターと、同じ高さのライフラインノードを 3 つ追加します。Browser、Relying party(あなたのアプリ)、OpenID provider です。
- 13 個のメッセージを、番号付きの直線のエッジとして上から下へ描きます。レスポンスは破線にし、フロントチャネルとバックチャネルで色を変えます。
- シーケンスパックの複合フラグメントを、鍵の取得と検証を囲むようにオペレーター
criticalで追加し、UserInfo の呼び出しを囲む 2 つ目の複合フラグメントをoptで追加します。 - 基本パックのテキストを Relying party の横に追加し、セッションの値と検証、および色の凡例を書きます。
- 基本パックの Markdown ブロックに Discovery、PKCE、デコードした ID トークンを書き、セキュリティパックのトークンノードを 2 つ、ID トークンとアクセストークン用に追加します。
この例で使うダイアグラムノードは 9 個で、Free プランの範囲に収まります。AI エージェントから MCP 経由で描くこともできます。
よくある質問
OAuth 2.0 と OpenID Connect の違いは何ですか?
OAuth 2.0 は、アプリに API を呼び出すためのアクセストークンを渡します。OpenID Connect はその上にサインインを加えます。openid スコープ、ユーザーが誰かを示す ID トークン、標準の Discovery エンドポイントと UserInfo エンドポイントです。
ID トークンはアクセストークンと同じですか? いいえ。ID トークンは、誰がサインインしたかを示す、あなたのアプリ向けの署名付き JWT です。アクセストークンは UserInfo を含む API 向けです。ID トークンを API に送ったり、アクセストークンをログインの証拠として受け入れたりしないでください。
nonce をすでに送っているなら PKCE は不要ですか? 両方使ってください。RFC 9700 はパブリッククライアントに PKCE を要求しています。機密クライアントは追加の対策をしたうえで nonce に頼ってもよいとされていますが、PKCE は盗まれたコードの引き換えを防ぎます。nonce が検出できるのは、トークンレスポンスの後で、注入されたコードだけです。
ID トークンはどう検証しますか?
kid に対応する JWKS の鍵で署名を検証し、iss が発行者と等しいこと、aud に自分の client ID が含まれること、exp を過ぎていないこと、iat が新しいこと、nonce がセッションと一致することを確認します。
/.well-known/openid-configuration とは何ですか?
OpenID Connect の Discovery ドキュメントです。プロバイダーの発行者、エンドポイント、アルゴリズム、jwks_uri が載っており、アプリは 1 つの URL から自分を設定できます。