本文へスキップ
ecdsa.com
← すべての記事

OIDC の ID トークン検証を手順ごとに解説

ID トークンは、独自のルールを持つ JWT です。ユーザーを実際に認証できたのか、署名付きのデータを解析しただけなのかは、仕様に番号付きで定められた 13 のステップで決まります。この記事では、各ステップと、それを実装したバリデーター、そして Keycloak のレルムが発行した実際のトークンを与えたときにバリデーターが検出した問題を紹介します。

要点: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暗号化を登録した場合は復号ライブラリ
2iss がディスカバリで得た発行者と完全に一致発行者を渡せばライブラリ
3aud に自分の client_id が含まれ、信頼しないオーディエンスは含まれない前半はライブラリ、後半は自前のことが多い
4、5azp がある場合はその検証(自分の client_id との一致の確認を含む場合あり)実装による
6発行者の鍵による署名。TLS 経由でトークンエンドポイントから直接受け取ったトークンに限り省略可ライブラリ
7alg が RS256、または登録した id_token_signed_response_alg固定すればライブラリ
8HS256/384/512 の場合、鍵は自分の client_secretライブラリ
9現在時刻が exp より前ライブラリ
10iat が現在時刻から離れすぎていない自前
11nonce がリクエストで送った値と一致自前、または想定する 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 ライブラリには知りようのないステップを追加しています。それらのステップは、ログイン試行やクライアントの登録内容に依存するからです。

validate-id-token.mjs(jose 6.2.13、Node.js 24.22)
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 で検証しました。

出力:Keycloak 26.8.1 での実際のログインを web-app として検証(node:24.22.0-bookworm-slim)
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 を渡せば、それをチェックします。

openid-client-flow.mjs(openid-client 6.8.8)
// 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 とアクセストークンをファイルに書き出し、次のプログラムでそれらをチェックしました。

main.go(go-oidc v3.21.0、Go 1.27.3)
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)
	}
}
出力(golang:1.27.3-trixie)
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 つのプロバイダーのディスカバリドキュメントに記載されていた内容は次のとおりです。

discovery.py
# 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')}")
出力(python:3.14.8-slim-trixie)
== 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 に切り替わることはありません。

よくある間違い

  1. ID トークンをアクセストークンであるかのように検証する、またはその逆:ID トークンは、誰がログインしたかをクライアントに伝えるもので、API を呼び出すための認証情報ではありません。API は、自分をオーディエンスとするアクセストークンを受け入れるべきです(typ: at+jwt については JWT 検証の間違いで説明しています)。
  2. 「TLS で届いたから」と署名の検証を省く:ステップ 6 がそれを認めているのは、サーバーがトークンエンドポイントから直接受け取ったトークンに限られます。モバイルアプリやブラウザーがバックエンドに送信してくる ID トークンは、プロバイダーではなくクライアントから来たものなので、すべてのステップで検証しなければなりません。
  3. nonce を保存しない:nonce を生成しても比較しないのでは、送信しないのと同じです。
  4. email でユーザーをログインさせる:安定していて一意なのは、iss と sub の組み合わせだけです(§5.7)。メールアドレスで照合すると、受け入れているいずれかのプロバイダーで未検証のアドレスを取得できる人なら誰でも、そのアドレスを持つアカウントを乗っ取れてしまいます。
  5. 鍵をハードコードする:鍵は jwks_uri から取得し、ローテーションに追従してください。詳しくはダウンタイムなしの JWKS 鍵ローテーションを参照してください。

出典