一、id_token 是什么,以及它解决的问题
OIDC(OpenID Connect)在 OAuth 2.0 之上构建了一层身份认证能力。OAuth 2.0 本身只解决授权(authorization)——它告诉资源服务器"这个 access_token 持有者被允许访问某些资源",但它从不保证"这个 token 的持有者是谁"。把 access_token 当成身份凭证使用,是一个经典且危险的误用。
id_token 正是为填补这个空缺而生:它是一个由 OP(OpenID Provider,身份提供方)签发、面向 RP(Relying Party,依赖方/客户端) 的、携带用户身份断言的 JWT(JSON Web Token)。
核心区别可以用一句话概括:
| access_token | Resource Server(资源服务器) | 授权——“能访问什么” |
| id_token | Client / RP(依赖方) | 认证——“用户是谁” |
id_token 的格式必须是 JWT,且必须签名(JWS,JSON Web Signature)。这是 OIDC 与 OAuth 2.0 的硬性差异之一。
二、id_token 的结构
id_token 是一个标准 JWT,三段式:Header.Payload.Signature,各段用 Base64URL 编码后以 . 连接。
2.1 Header(头部)
{
"alg": "RS256",
"typ": "JWT",
"kid": "2024-key-01"
}
- alg:签名算法。OIDC 默认且最常见为 RS256(RSA + SHA-256)。
- kid(Key ID):标识本次签名使用的密钥,是 RP 在校验时从 JWKS(JSON Web Key Set)中精确定位公钥的关键。
2.2 Payload(声明集,Claims)
OIDC 规定了一组标准声明,下面是签发与校验中最关键的几个:
| iss | Issuer(签发者) | 是 | OP 的标识 URL,校验时必须精确字符串匹配 |
| sub | Subject(主体) | 是 | 用户在该 OP 下的唯一且不可变标识符 |
| aud | Audience(受众) | 是 | 接收方,通常为 RP 的 client_id |
| exp | Expiration Time(过期时间) | 是 | Unix 时间戳,过期后必须拒绝 |
| iat | Issued At(签发时间) | 是 | 签发时刻 |
| nonce | 随机串 | 条件 | 防重放,绑定到一次认证请求 |
| auth_time | 认证发生时间 | 条件 | 配合 max_age 使用 |
| azp | Authorized Party(被授权方) | 条件 | 当 aud 含多个值时标识实际使用方 |
一个典型的 payload:
{
"iss": "https://op.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"exp": 1735689600,
"iat": 1735686000,
"nonce": "n-0S6_WzA2Mj",
"auth_time": 1735685990,
"email": "alice@example.com",
"email_verified": true
}
关于 sub:它在同一个 issuer 内唯一标识一个用户。RP 判断"是否同一用户"的正确依据是 (iss, sub) 组合,而非 email、phone 等可变信息。把 email 当主键是一个常见的安全/正确性漏洞。
2.3 Signature(签名)
签名覆盖 Base64URL(Header) + "." + Base64URL(Payload),使用 OP 私钥生成,RP 用对应公钥校验。
三、签发流程(OP 视角)
3.1 何时签发
id_token 在 OIDC 各种 flow 的 Token Endpoint 响应中签发(Authorization Code Flow),或在 Authorization Endpoint 直接返回(Implicit / Hybrid Flow)。当前最佳实践是 Authorization Code Flow + PKCE(Proof Key for Code Exchange),Implicit Flow 已被 OAuth 2.1 弃用。
Authorization Code Flow 下,token 响应形如:
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1Ni␣…",
"refresh_token": "…"
}
3.2 签名算法选择
| RSA | RS256 | 最通用,互操作性最好,默认首选 |
| ECDSA | ES256 | 密钥更短、性能更好,逐渐普及 |
| HMAC | HS256 | 对称密钥,RP 与 OP 共享密钥,仅适合机密客户端的特定场景 |
非对称算法(RS256/ES256)是主流:OP 持有私钥签名,公钥通过 JWKS 公开分发,RP 无需持有任何密钥即可校验。
绝对不要接受 alg: none。这是 JWT 历史上最著名的漏洞类别——攻击者把算法改成 none 并去掉签名,若校验方盲信 header 中的 alg,即可伪造任意身份。
3.3 密钥管理与轮换
- OP 通过 JWKS 端点(通常是 /.well-known/jwks.json)发布公钥集合,每个密钥带唯一 kid。
- 密钥轮换时,OP 应先发布新公钥(与旧公钥并存于 JWKS 中),再切换为用新私钥签名。这给了 RP 缓存刷新的时间窗口,避免轮换瞬间校验失败。
- 签名时把 kid 写入 header,RP 据此选用正确公钥。
3.4 nonce 的回填
如果授权请求中 RP 传了 nonce,OP 必须把它原样写入 id_token 的 nonce 声明。这是防重放链条的 OP 端职责。
四、校验流程(RP 视角)——核心中的核心
校验是整个机制安全性的落脚点。签发由 OP 负责,但绝大多数攻击面在 RP 的校验实现里。 以下步骤缺一不可。
4.1 完整校验清单
#mermaid-svg-55jusKJ201JMwZS2{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-55jusKJ201JMwZS2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-55jusKJ201JMwZS2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-55jusKJ201JMwZS2 .error-icon{fill:#552222;}#mermaid-svg-55jusKJ201JMwZS2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-55jusKJ201JMwZS2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-55jusKJ201JMwZS2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-55jusKJ201JMwZS2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-55jusKJ201JMwZS2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-55jusKJ201JMwZS2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-55jusKJ201JMwZS2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-55jusKJ201JMwZS2 .marker.cross{stroke:#333333;}#mermaid-svg-55jusKJ201JMwZS2 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-55jusKJ201JMwZS2 p{margin:0;}#mermaid-svg-55jusKJ201JMwZS2 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster-label text{fill:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster-label span{color:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster-label span p{background-color:transparent;}#mermaid-svg-55jusKJ201JMwZS2 .label text,#mermaid-svg-55jusKJ201JMwZS2 span{fill:#333;color:#333;}#mermaid-svg-55jusKJ201JMwZS2 .node rect,#mermaid-svg-55jusKJ201JMwZS2 .node circle,#mermaid-svg-55jusKJ201JMwZS2 .node ellipse,#mermaid-svg-55jusKJ201JMwZS2 .node polygon,#mermaid-svg-55jusKJ201JMwZS2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .rough-node .label text,#mermaid-svg-55jusKJ201JMwZS2 .node .label text,#mermaid-svg-55jusKJ201JMwZS2 .image-shape .label,#mermaid-svg-55jusKJ201JMwZS2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-55jusKJ201JMwZS2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .rough-node .label,#mermaid-svg-55jusKJ201JMwZS2 .node .label,#mermaid-svg-55jusKJ201JMwZS2 .image-shape .label,#mermaid-svg-55jusKJ201JMwZS2 .icon-shape .label{text-align:center;}#mermaid-svg-55jusKJ201JMwZS2 .node.clickable{cursor:pointer;}#mermaid-svg-55jusKJ201JMwZS2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-55jusKJ201JMwZS2 .arrowheadPath{fill:#333333;}#mermaid-svg-55jusKJ201JMwZS2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-55jusKJ201JMwZS2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-55jusKJ201JMwZS2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-55jusKJ201JMwZS2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-55jusKJ201JMwZS2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-55jusKJ201JMwZS2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-55jusKJ201JMwZS2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .cluster text{fill:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster span{color:#333;}#mermaid-svg-55jusKJ201JMwZS2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-55jusKJ201JMwZS2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-55jusKJ201JMwZS2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-55jusKJ201JMwZS2 .icon-shape,#mermaid-svg-55jusKJ201JMwZS2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-55jusKJ201JMwZS2 .icon-shape p,#mermaid-svg-55jusKJ201JMwZS2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-55jusKJ201JMwZS2 .icon-shape .label rect,#mermaid-svg-55jusKJ201JMwZS2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-55jusKJ201JMwZS2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-55jusKJ201JMwZS2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-55jusKJ201JMwZS2 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
否
是
失败
成功
是
否
是
否
收到 id_token
解析三段,读取 Header 的 alg / kid
alg 是否在白名单内?(拒绝 none、拒绝意外的 HS256)
拒绝
按 kid 从缓存的 JWKS 取公钥
缓存命中?
刷新 JWKS 再取
验证签名
校验 iss 精确匹配
校验 aud 含本 client_id
校验 exp 未过期 (含时钟偏移容忍)
校验 iat 合理
本次发过 nonce?
校验 nonce 与会话中存的一致
跳过
用了 max_age?
校验 auth_time + max_age >= now
通过:建立用户会话
认证失败
4.2 逐项详解
(1) 算法校验——先于一切
不要信任 token header 里的 alg 来决定校验逻辑。RP 应基于自己的配置维护一个算法白名单(例如只接受 RS256),并拒绝一切不在白名单内的算法。尤其要防范:
- alg: none
- RS256 → HS256 混淆攻击:攻击者把算法从 RS256 改成 HS256,用 OP 的公钥(公开可得)当作 HMAC 密钥重新签名。若 RP 校验代码"根据 header 的 alg 自动选择算法"且把公钥当对称密钥喂进去,签名就会通过。根治办法是按配置固定算法,而非读 header。
(2) 签名验证
- 根据 kid 从 JWKS 取对应公钥。
- JWKS 应带缓存(按 Cache-Control 或固定 TTL),但遇到未知 kid 时应触发一次刷新,以适配密钥轮换。
- 注意防 JWKS 刷新被滥用为 DoS(限频)。
(3) iss 校验
与 RP 配置的 issuer 做精确字符串比较,不做模糊/子串匹配。多租户场景尤其要锁定到具体租户的 issuer。
(4) aud 校验
aud 可能是字符串或数组。RP 必须确认其中包含自己的 client_id。若 aud 含多个值,且存在 azp,还应校验 azp 等于本 client_id。这一步防止"为别的客户端签发的 token 被拿来冒用"。
(5) exp / iat 时间校验
- exp:当前时间必须早于 exp。
- 允许一个小的时钟偏移容忍(clock skew,通常 ≤ 60 秒),避免分布式系统轻微时间差导致误判。容忍值不宜过大。
- iat:可选地校验签发时间不在未来、不过分陈旧。
(6) nonce 校验——防重放
完整链条:RP 在发起授权请求时生成随机 nonce,存入用户会话(如 server-side session 或 HttpOnly Cookie 绑定的状态);OP 回填到 id_token;RP 收到后比对二者是否一致。这能防止旧的 id_token 被重放,并把 token 绑定到本次具体的认证会话。
注意区分 nonce 与 state:state 防 CSRF(Cross-Site Request Forgery)、维持请求上下文,作用在授权请求/回调环节;nonce 防 id_token 重放,作用在 token 本身。两者职责不同,都应使用。
(7) auth_time / max_age
若 RP 在授权请求中带了 max_age,OP 必须返回 auth_time。RP 校验 auth_time + max_age 是否仍在有效期内,用于对敏感操作强制"近期重新认证"。
4.3 一段示意性校验伪代码
// 概念示意,省略异常处理细节
public ClaimsPrincipal ValidateIdToken(string idToken)
{
var parameters = new TokenValidationParameters
{
// 1) 固定算法白名单,绝不读 header 的 alg 自动决定
ValidAlgorithms = new[] { "RS256" },
// 2) 签名公钥来自带缓存、自动刷新的 JWKS
IssuerSigningKeyResolver = (token, securityToken, kid, p)
=> _jwksCache.ResolveByKid(kid),
ValidateIssuerSigningKey = true,
// 3) iss 精确匹配
ValidateIssuer = true,
ValidIssuer = _config.Issuer,
// 4) aud 必须包含本 client_id
ValidateAudience = true,
ValidAudience = _config.ClientId,
// 5) 过期校验 + 有限时钟偏移
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(60),
};
var principal = _handler.ValidateToken(idToken, parameters, out _);
// 6) nonce 单独比对(库一般不自动做)
var nonce = principal.FindFirst("nonce")?.Value;
if (nonce != _session.ExpectedNonce)
throw new SecurityException("nonce mismatch");
return principal;
}
