要点:OpenID Connect の ID トークンを受け取るたびに、次の点を確認してください。署名は、プロバイダーの jwks_uri から取得した鍵で、登録したアルゴリズム(デフォルトは RS256)だけを使って検証します。iss がディスカバリドキュメントの issuer と完全に一致すること、aud に自分の client_id が含まれ、信頼しない値が含まれていないことを必須とします。azp がある場合、それは自分のクライアントのいずれかでなければなりません。exp が未来の時刻であることを必須とし、nonce はこのログイン試行のために保存しておいた値と比較します。トークンがアクセストークンやコードと一緒に届いた場合は、at_hash と c_hash もチェックしてください。そのうえで、ユーザーの識別には iss と sub の組を使い、決して email を使ってはいけません。
13 のステップ
errata set 2(2023年12月)を反映した現行版の OpenID Connect Core 1.0 §3.1.3.7 は、トークンエンドポイントから受け取った ID トークンに対してクライアントが行うチェックを列挙しています。要約すると次のとおりです。
| ステップ | チェック内容 | 通常の担当 |
|---|---|---|
| 1 | 暗号化を登録した場合は復号 | ライブラリ |
| 2 | iss がディスカバリで得た発行者と完全に一致 | 発行者を渡せばライブラリ |
| 3 | aud に自分の client_id が含まれ、信頼しないオーディエンスは含まれない | 前半はライブラリ、後半は自前のことが多い |
| 4、5 | azp がある場合はその検証(自分の client_id との一致の確認を含む場合あり) | 実装による |
| 6 | 発行者の鍵による署名。TLS 経由でトークンエンドポイントから直接受け取ったトークンに限り省略可 | ライブラリ |
| 7 | alg が RS256、または登録した id_token_signed_response_alg | 固定すればライブラリ |
| 8 | HS256/384/512 の場合、鍵は自分の client_secret | ライブラリ |
| 9 | 現在時刻が exp より前 | ライブラリ |
| 10 | iat が現在時刻から離れすぎていない | 自前 |
| 11 | nonce がリクエストで送った値と一致 | 自前、または想定する nonce を受け取るライブラリ |
| 12 | 要求した場合、acr が許容できる値 | 自前 |
| 13 | 要求した場合または max_age を送った場合、auth_time が十分に新しい | 自前 |
関連するチェックが 2 つ、別のセクションにあります。at_hash はアクセストークンを ID トークンに結び付け(§3.1.3.8、コードフローでは任意)、c_hash は、ハイブリッドフローで認可エンドポイントから返された ID トークンに認可コードを結び付けます(§3.3.2.10)。どちらも、ID トークンの alg に対応するハッシュ関数でトークンまたはコードのハッシュ値を計算し、その左半分を base64url エンコードしたものです。RS256 なら SHA-256 の先頭 128 ビットです。
各ステップを実装したバリデーター
以下のバリデーターは、JWT ライブラリが得意とする部分(署名、alg、iss、aud の包含チェック、exp)には jose を使い、JWT ライブラリには知りようのないステップを追加しています。それらのステップは、ログイン試行やクライアントの登録内容に依存するからです。
import { createHash } from "node:crypto";
import { jwtVerify } from "jose";
// at_hash and c_hash: the left half of the hash of the ASCII value, base64url-encoded,
// with the hash function of the ID token's alg (OIDC Core 3.1.3.6 and 3.3.2.11).
const HASH = { RS256: "sha256", PS256: "sha256", ES256: "sha256", RS384: "sha384", PS384: "sha384", ES384: "sha384", RS512: "sha512", PS512: "sha512", ES512: "sha512" };
export function halfHash(value, alg) {
const digest = createHash(HASH[alg]).update(value, "ascii").digest();
return digest.subarray(0, digest.length / 2).toString("base64url");
}
/**
* Validates an ID token as OpenID Connect Core 1.0 §3.1.3.7 describes.
* rp: { issuer, jwks, clientId, alg, trustedAudiences, trustedPresenters, maxIatAgeSeconds }
* expected: { nonce, maxAge?, accessToken?, code? } from this login's stored state and the response
*/
export async function validateIdToken(idToken, rp, expected) {
// Steps 2, 3 (aud contains client_id), 6, 7 and 9: issuer, audience, signature, alg, expiry.
const { payload: claims, protectedHeader } = await jwtVerify(idToken, rp.jwks, {
issuer: rp.issuer, // exact string from the discovery document
audience: rp.clientId,
algorithms: [rp.alg], // RS256 unless id_token_signed_response_alg says otherwise
requiredClaims: ["iss", "sub", "aud", "exp", "iat"],
clockTolerance: 30,
});
const now = Math.floor(Date.now() / 1000);
const fail = (step, why) => {
throw new Error(`step ${step}: ${why}`);
};
// Step 3, second half: reject audiences this client does not trust.
const untrusted = [claims.aud].flat().filter((a) => a !== rp.clientId && !rp.trustedAudiences.includes(a));
if (untrusted.length > 0) fail(3, `untrusted audience ${untrusted.join(", ")}`);
// Steps 4-5: an azp, when present, must name a client of this application.
if (claims.azp !== undefined && ![rp.clientId, ...rp.trustedPresenters].includes(claims.azp)) {
fail(5, `azp is ${claims.azp}`);
}
// Step 10: iat bounds how long nonces must be remembered.
if (now - claims.iat > rp.maxIatAgeSeconds) fail(10, `issued ${now - claims.iat} s ago`);
// Step 11: the nonce stored with this login attempt, compared exactly.
if (claims.nonce !== expected.nonce) fail(11, `nonce ${claims.nonce} does not match the stored value`);
// Step 13: when max_age was sent, auth_time is required and bounded.
if (expected.maxAge !== undefined) {
if (typeof claims.auth_time !== "number") fail(13, "auth_time missing");
if (now - claims.auth_time > expected.maxAge + 30) fail(13, "authentication too old");
}
// Hashes: tie the access token (at_hash) and the code (c_hash) to this ID token.
if (expected.accessToken !== undefined && claims.at_hash !== undefined) {
if (claims.at_hash !== halfHash(expected.accessToken, protectedHeader.alg)) fail("3.1.3.8", "at_hash mismatch");
}
if (expected.code !== undefined) {
if (claims.c_hash !== halfHash(expected.code, protectedHeader.alg)) fail("3.3.2.10", "c_hash missing or mismatch");
}
return claims;
}
rp.jwks は createRemoteJWKSet(new URL(discovery.jwks_uri)) で、発行者ごとに一度だけ作成します。ステップ 1 がないのは検証環境で ID トークンを暗号化していないから、ステップ 8 と 12 がないのは RS256 を使い、acr を要求していないからです。
実際のトークンで試す
手作りのトークンではなく実際のプロバイダーが発行したトークンでバリデーターを試すため、検証環境では Keycloak 26.8.1(quay.io/keycloak/keycloak:26.8.1、開発モード)を動かし、ユーザー 1 人とコンフィデンシャルクライアント 2 つを含むレルムを用意しました。保護対象のリライングパーティである web-app と、同じプロバイダーに登録された別のアプリケーションで、ID トークンに web-app も含まれるようにオーディエンスマッパーを設定した other-app です。ブラウザーの役は小さなテストハーネスが務めました。新しい state、nonce、PKCE のチャレンジを付けて認可 URL を開き、ログインフォームを送信し、リダイレクトからコードを取り出して、トークンエンドポイントで交換します。こうして得たすべての ID トークンを、web-app として validateIdToken で検証しました。
issuer http://127.0.0.1:8080/realms/demo
code flow callback parameters: state, session_state, iss, code
ID token header: alg RS256, typ JWT, kid present: true
ID token claims: exp, iat, auth_time, jti, iss, aud, sub, typ, azp, nonce, sid, at_hash, acr, email_verified, name, preferred_username, given_name, family_name, email
aud "web-app", azp web-app, at_hash present
code flow ID token valid
hybrid callback parameters: state, session_state, iss, code, id_token
front-channel ID token: c_hash present, at_hash absent
hybrid front-channel ID token, with its code valid
same ID token, code from the first login rejected: step 3.3.2.10: c_hash missing or mismatch
other-app ID token: aud ["other-app","web-app"], azp other-app
other-app token, aud-contains check only valid
other-app token, full validation rejected: step 3: untrusted audience other-app
first ID token, nonce of the second login rejected: step 11: nonce lsamKJG6vC-xAIOzo1LIrw does not match the stored value
first ID token, access token of the second login rejected: step 3.1.3.8: at_hash mismatch
それぞれの拒否によって防げることは次のとおりです。
- 別のアプリケーション向けに発行された ID トークン:
other-appのトークンは同じプロバイダーが署名したもので、audにweb-appが含まれています。多くの JWT ライブラリが行っている「audに自分のクライアント ID が含まれているか」だけを確認するチェックは、このトークンを受け入れました。ステップ 3 は信頼しないオーディエンスを拒否するよう定めており、ステップ 5 でも検出できたはずです。このトークンのazpはother-appだからです。どちらのチェックもなければ、同じプロバイダーに登録されたアプリケーションなら、攻撃者が登録したものも含めてどれでも、自分のアプリケーションにユーザーをログインさせるトークンを入手できてしまいます。 - 別のログイン試行のトークン:nonce のチェックは、有効で期限切れでもなく、正しく署名された ID トークンを、別の認可リクエストに属するという理由で拒否しました。このチェックがあるからこそ、攻撃者が自身のセッションで入手したトークンやコードを被害者のログインに注入することを防げます(ステップ 11)。また、nonce をログイン試行ごとに保存しなければならないのもこのためです。保存先は通常、
stateと同じ場所、つまりサーバーサイドのセッションか暗号化された Cookie です。 - アーティファクトの取り違え:
at_hashとc_hashは、アクセストークンとコードを ID トークンに結び付けます。ハイブリッドフローでは、フロントチャネルの ID トークンはブラウザーを経由して届きます。その隣にあるコードが、プロバイダーがこの ID トークンと一緒に発行したものであることを証明するのがc_hashです。
どちらのコールバックにも iss パラメーターがある点に注目してください。Keycloak は認可レスポンスにこのパラメーターを追加します(RFC 9207)。これにより、複数のプロバイダーとやり取りするクライアントは、コードを交換する前に、誤ったプロバイダーからのレスポンスを検出できます。
ライブラリに任せられること
認定を受けた OpenID Connect のクライアントライブラリは、必要な値を渡せば、これらのステップの大半を実行します。openid-client もその 1 つで、想定する nonce を渡せば、それをチェックします。
// The library route: openid-client 6.8.8 runs discovery, the code exchange and the ID token checks.
import * as client from "openid-client";
import { browserLogin } from "./login.mjs";
const issuer = new URL("http://127.0.0.1:8080/realms/demo");
// allowInsecureRequests only because the lab issuer is plain http; never in production.
const config = await client.discovery(issuer, "web-app", process.env.WEB, undefined, {
execute: [client.allowInsecureRequests],
});
async function signIn(expectedNonceOverride) {
const codeVerifier = client.randomPKCECodeVerifier();
const state = client.randomState();
const nonce = client.randomNonce();
const url = client.buildAuthorizationUrl(config, {
redirect_uri: "http://127.0.0.1:3000/callback",
scope: "openid email",
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: "S256",
state,
nonce,
});
const { location } = await browserLogin(url, "alice", process.env.PW);
const tokens = await client.authorizationCodeGrant(config, new URL(location), {
pkceCodeVerifier: codeVerifier,
expectedState: state,
expectedNonce: expectedNonceOverride ?? nonce,
idTokenExpected: true,
});
return tokens.claims();
}
const claims = await signIn();
console.log(`signed in: sub ${claims.sub.slice(0, 8)}..., email ${claims.email}, nonce checked`);
try {
await signIn(client.randomNonce());
} catch (e) {
console.log(`wrong expectedNonce: ${e.name}: ${e.message}; cause: ${e.cause?.message}`);
}
signed in: sub aa5e60df..., email alice@example.com, nonce checked
wrong expectedNonce: ClientError: unexpected JWT claim value encountered; cause: unexpected ID Token "nonce" claim value
browserLogin は、ユーザーのブラウザーの代わりを務める検証環境のハーネスです。実際のアプリケーションでは、認可 URL へはリダイレクトで移動し、location はコールバックのリクエストの URL になります。openid-client の下位レイヤーである oauth4webapi 3.8.8 は、azp について errata set 1 のルールも適用します。aud に複数の値がある場合、azp が存在し、クライアント ID と一致しなければなりません。
go-oidc v3.21.0 は、完全なクライアントではなく検証を担うライブラリで、ドキュメントにも「Verify は nonce の検証を行わない。それは呼び出し側の責任である」とはっきり書かれています。検証環境のドライバーは、同じレルムから取得した新しいトークン 2 つ、送信した nonce、そして別のログインの nonce とアクセストークンをファイルに書き出し、次のプログラムでそれらをチェックしました。
package main
// What go-oidc's IDTokenVerifier checks on its own, against ID tokens from the lab Keycloak realm.
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/coreos/go-oidc/v3/oidc"
)
func main() {
var h struct {
Issuer, IDToken, AccessToken, NonceSent, NonceOfAnotherLogin, OtherAppIDToken, OtherAccessToken string
}
data, _ := os.ReadFile("/node/handoff.json")
if err := json.Unmarshal(data, &h); err != nil {
panic(err)
}
ctx := context.Background()
ctx = oidc.InsecureIssuerURLContext(ctx, h.Issuer) // only because the lab issuer is plain http
provider, err := oidc.NewProvider(ctx, h.Issuer)
if err != nil {
panic(err)
}
verifier := provider.Verifier(&oidc.Config{ClientID: "web-app"})
token, err := verifier.Verify(ctx, h.IDToken)
fmt.Printf("%-44s err=%v\n", "Verify(web-app ID token)", err)
fmt.Printf("%-44s %v\n", " nonce equals the nonce we sent", token.Nonce == h.NonceSent)
fmt.Printf("%-44s %v\n", " nonce equals another login's nonce", token.Nonce == h.NonceOfAnotherLogin)
fmt.Printf("%-44s err=%v\n", " VerifyAccessToken(its access token)", token.VerifyAccessToken(h.AccessToken))
fmt.Printf("%-44s err=%v\n", " VerifyAccessToken(another access token)", token.VerifyAccessToken(h.OtherAccessToken))
other, err := verifier.Verify(ctx, h.OtherAppIDToken)
if err == nil {
var c struct {
Azp string `json:"azp"`
}
_ = other.Claims(&c)
fmt.Printf("%-44s err=<nil>, aud=%v, azp=%s\n", "Verify(other-app ID token)", other.Audience, c.Azp)
} else {
fmt.Printf("%-44s err=%v\n", "Verify(other-app ID token)", err)
}
}
Verify(web-app ID token) err=<nil>
nonce equals the nonce we sent true
nonce equals another login's nonce false
VerifyAccessToken(its access token) err=<nil>
VerifyAccessToken(another access token) err=access token hash does not match value in ID token
Verify(other-app ID token) err=<nil>, aud=[other-app web-app], azp=other-app
Verify は、nonce を何も知らないまま最初のトークンを受け入れました。続く 2 行の比較は呼び出し側が行ったものです。また、other-app 向けに発行されたトークンも受け入れました。オーディエンスのチェックが包含の確認だけで、azp を見ないからです。go-oidc を使う場合は、idToken.Nonce を保存しておいた値と比較し、アクセストークンを使うときは idToken.VerifyAccessToken を呼び出してください。さらに、プロバイダーが複数のオーディエンスを持つトークンを発行しうる場合は、オーディエンスのリストと azp を自分でチェックしてください。go-oidc が実際に返すエラーについては、expected audience や issuer did not match などのページをエラーデータベースに用意しています。
バリデーターを誤動作させるプロバイダーごとの違い
2026年10月20日時点で、4 つのプロバイダーのディスカバリドキュメントに記載されていた内容は次のとおりです。
# Prints issuer, jwks_uri and ID token signing algorithms from public OpenID Connect discovery documents.
import json
import urllib.request
URLS = [
"https://accounts.google.com/.well-known/openid-configuration",
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
"https://login.microsoftonline.com/common/.well-known/openid-configuration",
"https://samples.auth0.com/.well-known/openid-configuration",
"https://appleid.apple.com/.well-known/openid-configuration",
]
for url in URLS:
with urllib.request.urlopen(url, timeout=20) as response:
doc = json.load(response)
print(f"== {url}")
print(f" issuer {doc['issuer']}")
print(f" jwks_uri {doc['jwks_uri']}")
print(f" id_token_signing_alg_values_supported {doc.get('id_token_signing_alg_values_supported')}")
== https://accounts.google.com/.well-known/openid-configuration
issuer https://accounts.google.com
jwks_uri https://www.googleapis.com/oauth2/v3/certs
id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
issuer https://login.microsoftonline.com/{tenantid}/v2.0
jwks_uri https://login.microsoftonline.com/common/discovery/v2.0/keys
id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/.well-known/openid-configuration
issuer https://sts.windows.net/{tenantid}/
jwks_uri https://login.microsoftonline.com/common/discovery/keys
id_token_signing_alg_values_supported ['RS256']
== https://samples.auth0.com/.well-known/openid-configuration
issuer https://samples.auth0.com/
jwks_uri https://samples.auth0.com/.well-known/jwks.json
id_token_signing_alg_values_supported ['HS256', 'RS256', 'PS256']
== https://appleid.apple.com/.well-known/openid-configuration
issuer https://appleid.apple.com
jwks_uri https://appleid.apple.com/auth/keys
id_token_signing_alg_values_supported ['RS256']
Microsoft Entra ID:2 つのトークンバージョンとテンプレート形式の発行者
- バージョンはエンドポイントで決まる:
/oauth2/authorizeから発行される ID トークンは v1.0 で、issはhttps://sts.windows.net/{tid}/です。/oauth2/v2.0/authorizeからの場合は v2.0 で、issはhttps://login.microsoftonline.com/{tid}/v2.0です(ID トークンのクレームのリファレンス)。どちらなのかはverクレームでわかります。一方の発行者の形式で設定したバリデーターはもう一方を拒否し、.NET では IDX10205: Issuer validation failed というメッセージになります。アクセストークンの場合、バージョンを決めるのはエンドポイントではなく、アプリマニフェストにある API のrequestedAccessTokenVersionです。 {tenantid}は実際の値ではない:上記のマルチテナント用ディスカバリドキュメントは、{tenantid}というプレースホルダーを文字どおり含む発行者を返します。この文字列と完全一致で比較しても、決して一致しません。Microsoft のアクセストークンに関するガイダンスは、このプレースホルダーをトークンのtidで置き換えること、tidが GUID であることと、issがそのtidを入れたテンプレートと完全に一致することを確認すること、そして Entra が JWKS の各鍵に付けているissuerプロパティをチェックすることを求めています。- テナントは自分で制限する:マルチテナントの設定では、攻撃者が作成したものも含め、あらゆる Entra テナントのユーザーを受け入れます。アプリケーションが特定の組織向けである場合は、
tidの値の許可リストを用意してください。個人用 Microsoft アカウントのテナントは9188040d-6c67-4c5b-b112-36a304b66dadに固定されています。 - ユーザーは
tidと、oidまたはsubの組み合わせで識別する:2 つのテナントにある同じsubは、別々の 2 人のユーザーです。
Google:2 通りの発行者の表記と Android 向けの azp
issはhttps://accounts.google.comまたはaccounts.google.com:Google の OpenID Connect のドキュメントでは両方が有効とされていますが、ディスカバリドキュメントに記載されているのはhttps://の形式だけです。発行者をディスカバリから取得する厳格なバリデーターは、短い形式のトークンを拒否します。この 2 つの文字列だけをそのまま受け入れ(jose のissuerオプションには配列を渡せます)、それより緩い条件にはしないでください。- Android アプリが要求した場合でも
audは Web クライアント ID:Android アプリがバックエンド向けの ID トークンを要求すると、audはバックエンドの Web クライアント ID に、azpは Android アプリのクライアント ID になります。Android のクライアント ID は信頼する提示元のリストに入れ、azpのチェックは省かないでください。 hdで Workspace のドメインを制限する:特定の Google Workspace ドメインのユーザーだけにログインを許可する場合は、hdクレームをチェックしてください。Google のドキュメントによると、このクレームがない場合、そのアカウントは Google がホストするドメインに属していません。メールアドレスのドメインだけでは代わりになりません。
Auth0:末尾のスラッシュと HS256
- 発行者はスラッシュで終わる:
https://samples.auth0.com/のように、末尾にスラッシュが付きます。スラッシュなしでhttps://your-tenant.auth0.comと設定すると、すべてのトークンが検証に失敗します。go-oidc でこのエラーにあたるのは issuer did not match で、そのページの例がまさに末尾のスラッシュの欠落です。 - ID トークンが HS256 の場合がある:ディスカバリドキュメントには
HS256が記載されており、HS256 を使うよう設定したアプリケーションは、自身のクライアントシークレットで署名された ID トークンを受け取ります(ステップ 8)。サーバーサイドのコンフィデンシャルクライアントならこれでも動作しますが、それはシークレットが署名鍵になるということであり、シークレットを知っているサービスならどれでも ID トークンを発行できてしまいます。Auth0 自身の署名アルゴリズムのページも RS256 を推奨しています。アプリケーションの設定で RS256 を選び、algorithms: ["RS256"]で固定してください。そうすれば、設定の変更によって検証側が知らないうちに HMAC に切り替わることはありません。
よくある間違い
- ID トークンをアクセストークンであるかのように検証する、またはその逆:ID トークンは、誰がログインしたかをクライアントに伝えるもので、API を呼び出すための認証情報ではありません。API は、自分をオーディエンスとするアクセストークンを受け入れるべきです(
typ: at+jwtについては JWT 検証の間違いで説明しています)。 - 「TLS で届いたから」と署名の検証を省く:ステップ 6 がそれを認めているのは、サーバーがトークンエンドポイントから直接受け取ったトークンに限られます。モバイルアプリやブラウザーがバックエンドに送信してくる ID トークンは、プロバイダーではなくクライアントから来たものなので、すべてのステップで検証しなければなりません。
- nonce を保存しない:nonce を生成しても比較しないのでは、送信しないのと同じです。
emailでユーザーをログインさせる:安定していて一意なのは、issとsubの組み合わせだけです(§5.7)。メールアドレスで照合すると、受け入れているいずれかのプロバイダーで未検証のアドレスを取得できる人なら誰でも、そのアドレスを持つアカウントを乗っ取れてしまいます。- 鍵をハードコードする:鍵は
jwks_uriから取得し、ローテーションに追従してください。詳しくはダウンタイムなしの JWKS 鍵ローテーションを参照してください。
出典
- errata set 2 を反映した OpenID Connect Core 1.0:§2(ID トークン)、§3.1.3.7、§3.1.3.8、§3.3.2.10、§3.3.2.11、§5.7
- errata set 1 を反映した OpenID Connect Core 1.0:以前の
azpのルール - RFC 9207:OAuth 2.0 認可サーバーの発行者識別
- Microsoft:ID トークンのクレームのリファレンス、アクセストークンと発行者の検証
- Google:OpenID Connect
- Auth0:ID トークンの検証、署名アルゴリズム
- openid-client と go-oidc のドキュメント