跳到主要内容
ecdsa.com
← 全部文章

OIDC ID 令牌校验:逐步详解

ID 令牌是一种有自己规则的 JWT。规范中编号的 13 个步骤决定了你是真正完成了用户身份验证,还是只解析了一段签过名的数据。本文逐一介绍每个步骤,给出实现这些步骤的校验器,并展示用 Keycloak realm 签发的真实令牌测试时它拦下了什么。

简短回答:对每个 OpenID Connect ID 令牌,都要用提供商 jwks_uri 中的密钥验证签名,且只接受你注册的算法(默认为 RS256);要求 iss 与发现文档中的 issuer 完全相等;要求 aud 包含你的 client_id,且不含任何你不信任的受众;如果存在 azp,它必须是你自己的某个客户端;要求 exp 晚于当前时间;并把 nonce 与你为这次登录尝试保存的值比较。令牌随访问令牌或授权码一起返回时,检查 at_hash 和 c_hash。最后用 iss 和 sub 这一对值识别用户,绝不能用 email。

13 个步骤

按纳入勘误集 2(2023年12月)的现行文本,OpenID Connect Core 1.0 §3.1.3.7 列出了客户端对来自令牌端点的 ID 令牌所做的检查。简要如下:

步骤检查通常由谁负责
1解密(如果注册了加密)库
2iss 与发现文档中的颁发者完全相等库(前提是你传入了颁发者)
3aud 包含你的 client_id,且不含你不信任的受众库负责前一半;后一半往往要你自己做
4、5如果存在 azp,则校验它(可以包括:它等于你的 client_id)因实现而异
6签名,用颁发者的密钥验证;只有通过 TLS 直接从令牌端点收到的令牌才可以省略库
7alg 为 RS256 或你注册的 id_token_signed_response_alg库(前提是你固定了算法)
8对于 HS256/384/512,密钥是你的 client_secret库
9当前时间早于 exp库
10iat 与当前时间相差不太远你
11nonce 等于请求中发送的值你,或可以传入预期 nonce 的库
12acr 可以接受(如果请求了)你
13auth_time 足够近(如果请求了它或发送了 max_age)你

另有两项相关检查位于其他章节:at_hash 把访问令牌与 ID 令牌绑定在一起(§3.1.3.8,在授权码流程中是可选的);c_hash 在混合流程中把授权码与从授权端点返回的 ID 令牌绑定在一起(§3.3.2.10)。两者都是令牌或授权码哈希值的左半部分,经过 base64url 编码,所用哈希与 ID 令牌的 alg 相对应:对于 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,开发模式),其中的 realm(域)包含一个用户和两个机密客户端:web-app 是受保护的依赖方;other-app 是同一提供商下的另一个应用,配置了受众映射器,使它的 ID 令牌也列出 web-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。只问“aud 是否包含我的客户端 ID”的检查接受了它,而大多数 JWT 库做的正是这种检查。第 3 步要求拒绝你不信任的受众,第 5 步同样能拦住它:它的 azp 是 other-app。两项检查都没有时,在同一提供商注册的任何应用(包括攻击者注册的应用)都能拿到让用户登录进你的应用的令牌。
  • 来自另一次登录尝试的令牌。nonce 检查拒绝了一个有效、未过期、签名正确的 ID 令牌,因为它属于另一个授权请求。正是这项检查阻止攻击者把在自己会话中获得的令牌或授权码注入受害者的登录流程(第 11 步),这也是 nonce 必须按每次登录尝试分别保存的原因,通常与 state 存放在同一个服务端会话或加密 cookie 中。
  • 被混用的令牌和授权码。at_hash 和 c_hash 把访问令牌和授权码绑定到 ID 令牌。在混合流程中,前端通道的 ID 令牌经由浏览器传递,而 c_hash 正是用来证明与它一起传来的授权码就是提供商与它一同签发的那个。

注意两次回调中都有 iss 参数。Keycloak 把它加入授权响应(RFC 9207),这样与多个提供商交互的客户端在兑换授权码之前,就能发现响应来自错误的提供商。

库替你做了什么

经过认证的 OpenID Connect 客户端库,只要你把所需的值交给它,就会执行其中大部分步骤。openid-client 就是这样的库;传入预期的 nonce,它就会检查 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 还应用了勘误集 1 中关于 azp 的规则:aud 有多个值时,azp 必须存在且等于客户端 ID。

go-oidc v3.21.0 是一个验证器,而不是完整的客户端,它的文档说得很直白:“Verify 不做 nonce 校验,这是调用方的职责。”实验驱动脚本把来自同一 realm 的两个新令牌、它发送的 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 的情况下接受了第一个令牌;接下来两行中的比较是调用方做的。它还接受了为 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:两种令牌版本和模板形式的颁发者

  • 版本由端点决定。来自 /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 识别用户。两个租户中相同的 sub 代表两个不同的用户。

Google:两种颁发者写法,以及 Android 的 azp

  • iss 可能是 https://accounts.google.com,也可能是 accounts.google.com。Google 的 OpenID Connect 文档把两者都列为有效值,而发现文档中只有 https:// 形式。从发现文档读取颁发者的严格校验器会拒绝使用简短形式的令牌;应当只接受这两个字符串(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,配置为使用它的应用收到的 ID 令牌是用该应用的客户端密钥签名的(第 8 步)。这对服务端的机密客户端可行,但意味着这个密钥本身就是签名密钥,任何知道它的服务都能签发 ID 令牌。Auth0 自己的签名算法页面推荐使用 RS256。在应用设置中选择 RS256,并固定 algorithms: ["RS256"],这样配置变更就无法悄悄把你的验证器切换到 HMAC。

常见错误

  1. 把 ID 令牌当作访问令牌来校验,或者反过来。ID 令牌告诉你的客户端是谁登录了;它不是调用你的 API 的凭证。API 应当接受以自己为受众的访问令牌(JWT 验证常见错误介绍了 typ: at+jwt)。
  2. 以“它是通过 TLS 传来的”为由跳过签名验证。第 6 步只允许对你的服务器直接从令牌端点收到的令牌这样做。移动应用或浏览器提交给你后端的 ID 令牌来自客户端,而不是来自提供商,必须完整验证。
  3. 不保存 nonce。生成 nonce 却从不比较,等于没有发送。
  4. 按 email 让用户登录。只有 iss 加 sub 是稳定且唯一的(§5.7)。按电子邮件匹配时,只要有人能在你接受的任意一家提供商那里拿到一个未经验证的地址,就能接管使用该地址的账号。
  5. 把密钥硬编码。从 jwks_uri 获取密钥,并跟进轮换;参见零停机的 JWKS 密钥轮换。

来源