ID Token 是 OIDC(OpenID Connect)区别于纯 OAuth 2.0 的核心产物。OAuth 2.0 关心的是"授权"------给你一把能开门的钥匙;OIDC 关心的是"认证"------告诉 RP(Relying Party,依赖方)"这个人到底是谁"。而这个"告诉"的动作要可信,就必须依赖签名。本文系统地拆解 ID Token 签名的方方面面:它解决什么问题、用 JWS 怎么承载、有哪些算法及其陷阱、密钥如何发布与轮换、验签的精确步骤、有哪些经典攻击,以及在 ASP.NET Core 里如何落地。
一、为什么 ID Token 必须被签名
ID Token 本质是一份身份断言(identity assertion):IdP(Identity Provider,身份提供方)用结构化的方式声明"在某时刻,主体 sub 通过了认证,其受众是 client_id"。RP 拿到这份断言后,会据此创建本地会话、放行受保护资源。
问题在于:这份断言通常经过浏览器、前端、各种中间环节流转。如果它只是一段明文 JSON,任何能接触到传输链路的人都能伪造一个 "sub": "admin" 塞给 RP。RP 凭什么相信这是 IdP 签发的,而不是攻击者捏造的?
签名同时提供三重保证:
| 保证 | 含义 | 对称签名 | 非对称签名 |
|---|---|---|---|
| 完整性(integrity) | 内容未被篡改 | ✅ | ✅ |
| 来源认证(origin authentication) | 确实来自持有密钥的一方 | ✅ | ✅ |
| 不可否认性(non-repudiation) | 签发方无法抵赖 | ❌(双方共享密钥) | ✅(私钥唯一持有) |
正因为如此,OIDC Core 规范明确要求:ID Token 必须使用 JWS(JSON Web Signature)签名 ,除非它被加密(那也要先签后加)。唯一的例外是授权码流程(Authorization Code Flow)下、客户端在注册时显式声明 id_token_signed_response_alg = none、且 Token 通过 TLS 直连从令牌端点取回------这种场景在生产环境几乎都应禁止。
二、JWS:ID Token 签名的承载格式
ID Token 是一个 JWT(JSON Web Token),而带签名的 JWT 在物理上就是一个 JWS Compact Serialization(紧凑序列化)。它由三段经过 Base64URL 编码的内容用点号连接而成:
eyJhbGciOiJSUzI1NiIsImtpZCI6IjJhYiJ9 . eyJpc3MiOiJodHRwczovL2lkcCJ9 . NHVaYe26MbtO...
└──────── Header ────────┘ └─── Payload ───┘ └──── Signature ────┘
(JOSE Header) (Claims) (签名值)
三段各自的职责:
- Header(JOSE Header) :声明
alg(签名算法)和kid(Key ID,用于定位验签公钥),常含typ: JWT。 - Payload(Claims) :
iss、sub、aud、exp、iat、nonce,以及at_hash、c_hash等。 - Signature:对前两段拼接结果的签名值。
签名输入的精确定义
这是最容易被忽视却最关键的细节。签名不是 对原始 JSON 计算的,而是对已经 Base64URL 编码后的 Header 和 Payload 的拼接串计算的:
Signing Input = ASCII( BASE64URL(UTF8(Header)) + "." + BASE64URL(Payload) )
Signature = Sign( Signing Input, Key )
JWT = BASE64URL(UTF8(Header)) + "."
+ BASE64URL(Payload) + "."
+ BASE64URL(Signature)
之所以这样设计,是为了规避"JSON 规范化"难题:不同库序列化 JSON 时空格、键顺序、转义都可能不同,如果对原始 JSON 签名,验签方几乎无法重现完全相同的字节。改为对编码后的字符串签名,验签方拿到什么字节就验什么字节,无需重新序列化,彻底消除了歧义。
注意 Base64URL 与标准 Base64 的差异:用 - 替换 +、用 _ 替换 /、去掉末尾的 = 填充。这是 JOSE 体系全程使用的编码方式。
三、签名算法详解
算法标识符在 JWA(JSON Web Algorithms,RFC 7518)及补充规范中定义。命名规律是"算法族 + 摘要长度":尾部的 256/384/512 指 SHA-256/384/512。
#mermaid-svg-5aIloI2JS6271XGx{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-5aIloI2JS6271XGx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5aIloI2JS6271XGx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5aIloI2JS6271XGx .error-icon{fill:#552222;}#mermaid-svg-5aIloI2JS6271XGx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5aIloI2JS6271XGx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5aIloI2JS6271XGx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5aIloI2JS6271XGx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5aIloI2JS6271XGx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5aIloI2JS6271XGx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5aIloI2JS6271XGx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5aIloI2JS6271XGx .marker.cross{stroke:#333333;}#mermaid-svg-5aIloI2JS6271XGx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5aIloI2JS6271XGx p{margin:0;}#mermaid-svg-5aIloI2JS6271XGx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster-label text{fill:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster-label span{color:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster-label span p{background-color:transparent;}#mermaid-svg-5aIloI2JS6271XGx .label text,#mermaid-svg-5aIloI2JS6271XGx span{fill:#333;color:#333;}#mermaid-svg-5aIloI2JS6271XGx .node rect,#mermaid-svg-5aIloI2JS6271XGx .node circle,#mermaid-svg-5aIloI2JS6271XGx .node ellipse,#mermaid-svg-5aIloI2JS6271XGx .node polygon,#mermaid-svg-5aIloI2JS6271XGx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .rough-node .label text,#mermaid-svg-5aIloI2JS6271XGx .node .label text,#mermaid-svg-5aIloI2JS6271XGx .image-shape .label,#mermaid-svg-5aIloI2JS6271XGx .icon-shape .label{text-anchor:middle;}#mermaid-svg-5aIloI2JS6271XGx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .rough-node .label,#mermaid-svg-5aIloI2JS6271XGx .node .label,#mermaid-svg-5aIloI2JS6271XGx .image-shape .label,#mermaid-svg-5aIloI2JS6271XGx .icon-shape .label{text-align:center;}#mermaid-svg-5aIloI2JS6271XGx .node.clickable{cursor:pointer;}#mermaid-svg-5aIloI2JS6271XGx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5aIloI2JS6271XGx .arrowheadPath{fill:#333333;}#mermaid-svg-5aIloI2JS6271XGx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5aIloI2JS6271XGx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5aIloI2JS6271XGx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5aIloI2JS6271XGx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5aIloI2JS6271XGx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5aIloI2JS6271XGx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5aIloI2JS6271XGx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .cluster text{fill:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster span{color:#333;}#mermaid-svg-5aIloI2JS6271XGx 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-5aIloI2JS6271XGx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5aIloI2JS6271XGx rect.text{fill:none;stroke-width:0;}#mermaid-svg-5aIloI2JS6271XGx .icon-shape,#mermaid-svg-5aIloI2JS6271XGx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5aIloI2JS6271XGx .icon-shape p,#mermaid-svg-5aIloI2JS6271XGx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5aIloI2JS6271XGx .icon-shape .label rect,#mermaid-svg-5aIloI2JS6271XGx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5aIloI2JS6271XGx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5aIloI2JS6271XGx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5aIloI2JS6271XGx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JWS 签名算法
对称 / MAC
非对称 / 数字签名
HS256 / HS384 / HS512
HMAC + SHA
RS256 / RS384 / RS512
RSASSA-PKCS1-v1_5
PS256 / PS384 / PS512
RSASSA-PSS
ES256 / ES384 / ES512
ECDSA
EdDSA
Ed25519 / Ed448
none
无签名(危险)
1. HMAC 系列(HS256/384/512)------对称
基于 HMAC + SHA。签发与验签用同一把密钥 。在 OIDC 里,这把密钥就是 client_secret。
这带来两个直接后果:其一,只有**机密客户端(confidential client)**才能用------SPA、移动 App 这类公开客户端(public client)没有 secret,无法验签;其二,IdP 必须知道每个客户端的 secret,且针对不同客户端产生不同签名。HMAC 速度快,但密钥分发与保密压力大,因此 OIDC 主流不推荐它作为 ID Token 的签名手段。
2. RSA PKCS#1 v1.5 系列(RS256/384/512)------非对称
RS256 = RSASSA-PKCS1-v1_5 + SHA-256。这是 OIDC 的默认且强制实现 的算法,生态兼容性最好。特点是确定性:相同输入相同密钥,签名值恒定。PKCS#1 v1.5 填充方案历史悠久,密码学界近年更倾向 PSS,但 RS256 因兼容性仍占绝对主流。
3. RSA-PSS 系列(PS256/384/512)------非对称
PS256 = RSASSA-PSS + SHA-256 + MGF1(SHA-256),盐长等于摘要长。PSS 引入随机盐,因此是概率性的:同一份内容每次签名结果都不同。这在可证明安全性上优于 PKCS#1 v1.5,是新系统的推荐选择,但部分老旧 RP 库可能不支持。
4. ECDSA 系列(ES256/384/512)------非对称(有坑)
基于椭圆曲线。ES256 用 P-256 曲线,ES384 用 P-384,ES512 用 P-521(注意是 521 不是 512)。优点是密钥短、签名快。
这里有一个经典实现陷阱 :JWS 规定 ECDSA 签名必须是固定长度的 R ‖ S 原始拼接(ES256 下 R、S 各 32 字节,共 64 字节)。而 OpenSSL、Java、.NET 等很多底层 API 默认输出的是 ASN.1/DER 编码 (带长度前缀、可变长)。如果直接把 DER 字节塞进 JWS,验签必然失败。在 .NET 里,要用 DSASignatureFormat.IeeeP1363FixedFieldConcatenation(成熟的 JsonWebTokenHandler 已内部处理好,但手写时务必注意)。
DER 格式(错误): 30 44 02 20 <R...> 02 20 <S...> ← 不能用于 JWS
P1363 格式(正确):<R 固定32字节> <S 固定32字节> ← JWS 要求
5. EdDSA(Ed25519 / Ed448)------非对称
RFC 8037 引入。基于 Edwards 曲线,签名快、抗侧信道、无需随机数源(确定性),是现代密码学的明星。但 OIDC 生态采纳度仍不及 RS256/ES256,RP 端支持需要确认。
6. none 算法------无签名
alg: none 表示不签名,签名段为空。OIDC 仅在前述极严格条件下允许。任何生产 RP 的验签逻辑都绝不能接受 none------否则等于敞开大门。
OIDC 的算法约束小结
- ID Token 必须 用非
none算法签名(除极特殊例外)。 - RS256 是强制实现项,可作为兜底默认。
- IdP 通过 Discovery 文档的
id_token_signing_alg_values_supported公布支持的算法。 - 客户端注册时通过
id_token_signed_response_alg指定期望算法。
四、为什么 OIDC 几乎总是用非对称签名
把对称与非对称放在 OIDC 的真实拓扑里对比,结论就很清晰了:
RP N RP B RP A IdP(签发方) RP N RP B RP A IdP(签发方) #mermaid-svg-3Lw6S27zmICgFKJv{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-3Lw6S27zmICgFKJv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Lw6S27zmICgFKJv .error-icon{fill:#552222;}#mermaid-svg-3Lw6S27zmICgFKJv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Lw6S27zmICgFKJv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Lw6S27zmICgFKJv .marker.cross{stroke:#333333;}#mermaid-svg-3Lw6S27zmICgFKJv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Lw6S27zmICgFKJv p{margin:0;}#mermaid-svg-3Lw6S27zmICgFKJv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Lw6S27zmICgFKJv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Lw6S27zmICgFKJv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3Lw6S27zmICgFKJv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv .sequenceNumber{fill:white;}#mermaid-svg-3Lw6S27zmICgFKJv #sequencenumber{fill:#333;}#mermaid-svg-3Lw6S27zmICgFKJv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv .messageText{fill:#333;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Lw6S27zmICgFKJv .labelText,#mermaid-svg-3Lw6S27zmICgFKJv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .loopText,#mermaid-svg-3Lw6S27zmICgFKJv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Lw6S27zmICgFKJv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3Lw6S27zmICgFKJv .noteText,#mermaid-svg-3Lw6S27zmICgFKJv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Lw6S27zmICgFKJv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Lw6S27zmICgFKJv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Lw6S27zmICgFKJv .actorPopupMenu{position:absolute;}#mermaid-svg-3Lw6S27zmICgFKJv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3Lw6S27zmICgFKJv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Lw6S27zmICgFKJv .actor-man circle,#mermaid-svg-3Lw6S27zmICgFKJv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3Lw6S27zmICgFKJv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 非对称:IdP 持有 1 把私钥 任意 RP 用公钥即可独立验签无需保管任何机密 用私钥签所有 ID Token公钥(经 JWKS 公开)同一份公钥同一份公钥
非对称签名的核心优势:
- 密钥分发问题消失 :公钥本就是公开的,通过
jwks_uri自动发布,RP 不需要安全信道去拿密钥。 - RP 只需公钥验签:验签方拿不到、也不需要私钥,无法伪造 Token,职责天然隔离。
- 统一签名,多方复用:IdP 用同一把私钥服务所有 RP,签名结果一致;而 HMAC 需为每个客户端用其 secret 单独签。
- 覆盖所有客户端类型:SPA、移动端这类无 secret 的公开客户端也能验签。
对称签名只在"单一机密客户端、且不想引入公钥基础设施"的小场景里偶有价值。
五、JWK / JWKS:公钥的发布与发现
既然 RP 要用公钥验签,公钥怎么传给它?答案是 JWKS(JSON Web Key Set),通过 OIDC Discovery 自动发现。
发现链路
1. RP 读取 https://idp.example.com/.well-known/openid-configuration
2. 文档里有 "jwks_uri": "https://idp.example.com/.well-known/jwks.json"
3. RP 拉取该 jwks_uri,得到一组公钥(JWKS)
4. 验签时按 Token 头里的 kid 在这组键里挑出对应公钥
JWK 结构(以 RSA 公钥为例)
JWK 是公钥的 JSON 表示。一个 JWKS 就是 keys 数组,里面放多把 JWK:
json
{
"keys": [
{
"kty": "RSA", // Key Type:密钥类型
"use": "sig", // 用途:sig=签名验证,enc=加密
"kid": "2ab-2025-q2", // Key ID:与 Token 头的 kid 对应
"alg": "RS256", // 配套算法
"n": "0vx7agoeb...", // RSA 模数(modulus)
"e": "AQAB" // RSA 公开指数(exponent)
},
{
"kty": "EC",
"use": "sig",
"kid": "ec-2025-q2",
"crv": "P-256", // Curve:曲线
"x": "f83OJ3D2...", // 椭圆曲线点 X 坐标
"y": "x_FEzRu9..." // 椭圆曲线点 Y 坐标
}
]
}
不同 kty 暴露不同参数:RSA 是 n/e;EC 是 crv/x/y。注意 JWKS 里只放公钥分量 ,绝不含私钥参数(如 RSA 的 d、p、q)。
kid:连接 Token 与公钥的索引
kid 是验签的"路由键"。Token 头里写 "kid": "2ab-2025-q2",RP 就在 JWKS 中找 kid 相同的那把键。这个机制让 IdP 可以同时挂多把键------而这正是平滑轮换的基础。
如果 Token 头没有 kid(不推荐),RP 只能用 kty/alg 匹配后逐一尝试,效率与确定性都差。
JWKS 的缓存
RP 不会每次验签都去拉 JWKS------那样会拖垮 IdP。常见做法是按 Cache-Control 或固定 TTL 缓存。但缓存带来一个关键问题:当 IdP 轮换出新 kid 时,RP 缓存里还没有这把新键,验签会找不到 kid 。成熟的 RP 库(包括 .NET 的 ConfigurationManager)会处理这种"遇到未知 kid 时强制刷新一次 JWKS"的逻辑。这个缓存策略与下面的密钥轮换是一体两面。
六、签名验证的完整流程
把签发与验证放在一张时序图里:
jwks_uri IdP RP jwks_uri IdP RP #mermaid-svg-gTTC0tuOjRnaiEo2{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-gTTC0tuOjRnaiEo2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gTTC0tuOjRnaiEo2 .error-icon{fill:#552222;}#mermaid-svg-gTTC0tuOjRnaiEo2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gTTC0tuOjRnaiEo2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .marker.cross{stroke:#333333;}#mermaid-svg-gTTC0tuOjRnaiEo2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gTTC0tuOjRnaiEo2 p{margin:0;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gTTC0tuOjRnaiEo2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gTTC0tuOjRnaiEo2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .sequenceNumber{fill:white;}#mermaid-svg-gTTC0tuOjRnaiEo2 #sequencenumber{fill:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .messageText{fill:#333;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gTTC0tuOjRnaiEo2 .labelText,#mermaid-svg-gTTC0tuOjRnaiEo2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .loopText,#mermaid-svg-gTTC0tuOjRnaiEo2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gTTC0tuOjRnaiEo2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-gTTC0tuOjRnaiEo2 .noteText,#mermaid-svg-gTTC0tuOjRnaiEo2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gTTC0tuOjRnaiEo2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gTTC0tuOjRnaiEo2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actorPopupMenu{position:absolute;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor-man circle,#mermaid-svg-gTTC0tuOjRnaiEo2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-gTTC0tuOjRnaiEo2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 签发阶段 验证阶段 构造 Header(alg=RS256, kid=K1)+ PayloadSigningInput = b64(Header).b64(Payload)Signature = Sign(SigningInput, 私钥K1)返回 ID Token(三段拼接)1. 拆分三段,解码 Header2. 校验 alg 在白名单内(拒绝 none / 防混淆)3. 按 kid=K1 取公钥(缓存未命中则刷新)返回公钥 K14. Verify(SigningInput, Signature, 公钥K1)5. 校验 iss / aud / exp / iat / nonce6. 校验 at_hash / c_hash(如适用)
验签步骤的细节(对应 OIDC Core 3.1.3.7):
- 若加密则先解密(JWE 场景)。
- 校验
alg:必须在 RP 预先约定的白名单内,绝不能信任 Token 自报的算法类别(这是防御算法混淆的关键,详见第八节)。 - 按
kid定位公钥:从缓存的 JWKS 取,缺失则刷新。 - 执行签名验证 :对
SigningInput用公钥验签。只有这一步通过,后续 Claims 才可信------顺序很重要,先验签再读内容。 - 校验标准声明 :
iss等于预期 Issuer;aud包含本client_id(多受众时还要查azp);exp未过期;iat合理;nonce与发起时一致(防重放)。 - 校验绑定哈希 :
at_hash绑定 access_token、c_hash绑定授权码,确保它们与本 ID Token 同源未被掉包。
补充说明 at_hash/c_hash 的计算:取对应 Token 字符串,用与 ID Token 的 alg 匹配的摘要算法(RS256→SHA-256)做哈希,取左半截字节,再 Base64URL 编码。它本身写在已签名的 Payload 里,因此其完整性由 ID Token 签名间接保护------这是一种巧妙的"用签名保护其他工件"的设计。
七、密钥轮换(Key Rotation)
私钥用久了泄露风险累积,合规上也常要求定期更换。但直接"换掉私钥"会导致大量尚未过期的旧 Token 集体验签失败。kid + JWKS 多键并存让零停机轮换成为可能。
平滑轮换的标准时间线:
阶段 0 仅 K1 在 JWKS,用 K1 签名
JWKS: [K1] 签名用: K1
阶段 1 生成 K2,把 K2 公钥加入 JWKS;此时仍用 K1 签名
JWKS: [K1, K2] 签名用: K1 ← 让 RP 缓存提前拿到 K2
阶段 2 切换为用 K2 签名;K1 公钥保留在 JWKS
JWKS: [K1, K2] 签名用: K2 ← 旧 Token(K1签)仍可验
└─ 此重叠窗口需 ≥ (最长 Token 有效期 + JWKS 缓存 TTL)
阶段 3 确认无任何 K1 签发的有效 Token 后,移除 K1
JWKS: [K2] 签名用: K2
几个工程要点:
- 重叠窗口必须足够长:至少覆盖"最长 ID Token 有效期 + RP 端 JWKS 缓存的最坏 TTL",否则会出现"旧 Token 还没过期、但其公钥已从 JWKS 消失"的验签失败。
kid命名要稳定可辨:如带时间/轮次信息,便于排障和审计。- 先发布后启用:阶段 1 提前把新公钥放进 JWKS,给 RP 缓存留出同步时间,避免切换瞬间 RP 集体扑空。
- RP 侧要支持"未知 kid 触发刷新":这是兜底保障,即便重叠窗口估算偏短也能自愈。
八、签名相关的攻击与防御
签名机制最危险的漏洞,几乎都出在验签方对算法的处理上。
1. alg: none 攻击
攻击者把 Header 改成 {"alg":"none"},删掉签名段,直接伪造任意 Payload。如果 RP 的库"看到 none 就认为无需验签",任何 Token 都会被接受。
防御 :验签端维护算法白名单,显式拒绝 none。在 .NET 中通过 TokenValidationParameters.ValidAlgorithms 限定。
2. 算法混淆攻击(RS256 → HS256)
这是最经典也最隐蔽的一类。IdP 用 RS256(非对称),公钥是公开的。攻击者把 Header 的 alg 改成 HS256,然后用 IdP 的 RSA 公钥当作 HMAC 密钥去签。如果 RP 的验签逻辑是"按 Token 头里的 alg 决定算法,并把配置好的那把'密钥'拿来用",当它对 HS256 用 RSA 公钥做 HMAC 验证时------因为攻击者正是用同一把公钥签的------签名竟然通过了!
根因:让 Token 自报算法类别,且验签方在对称/非对称之间用了同一把"密钥材料"。
#mermaid-svg-MxTFljR4nTJSkEBT{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-MxTFljR4nTJSkEBT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MxTFljR4nTJSkEBT .error-icon{fill:#552222;}#mermaid-svg-MxTFljR4nTJSkEBT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MxTFljR4nTJSkEBT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT .marker.cross{stroke:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MxTFljR4nTJSkEBT p{margin:0;}#mermaid-svg-MxTFljR4nTJSkEBT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster-label text{fill:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster-label span{color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster-label span p{background-color:transparent;}#mermaid-svg-MxTFljR4nTJSkEBT .label text,#mermaid-svg-MxTFljR4nTJSkEBT span{fill:#333;color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .node rect,#mermaid-svg-MxTFljR4nTJSkEBT .node circle,#mermaid-svg-MxTFljR4nTJSkEBT .node ellipse,#mermaid-svg-MxTFljR4nTJSkEBT .node polygon,#mermaid-svg-MxTFljR4nTJSkEBT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .rough-node .label text,#mermaid-svg-MxTFljR4nTJSkEBT .node .label text,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape .label,#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape .label{text-anchor:middle;}#mermaid-svg-MxTFljR4nTJSkEBT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .rough-node .label,#mermaid-svg-MxTFljR4nTJSkEBT .node .label,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape .label,#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape .label{text-align:center;}#mermaid-svg-MxTFljR4nTJSkEBT .node.clickable{cursor:pointer;}#mermaid-svg-MxTFljR4nTJSkEBT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT .arrowheadPath{fill:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MxTFljR4nTJSkEBT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MxTFljR4nTJSkEBT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MxTFljR4nTJSkEBT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MxTFljR4nTJSkEBT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MxTFljR4nTJSkEBT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MxTFljR4nTJSkEBT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster text{fill:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster span{color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT 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-MxTFljR4nTJSkEBT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MxTFljR4nTJSkEBT rect.text{fill:none;stroke-width:0;}#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape p,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape .label rect,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MxTFljR4nTJSkEBT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MxTFljR4nTJSkEBT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MxTFljR4nTJSkEBT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 信任 Token 自报算法
且复用同一密钥材料
算法白名单 + 区分密钥类型
IdP 用 RS256
RSA 私钥签名
公钥公开发布
攻击者改 alg=HS256
用 RSA 公钥作 HMAC 密钥重签
RP 验签逻辑
❌ 验签通过,被攻破
✅ 拒绝
防御 :RP 必须固定预期算法 (只接受 RS256),不让 Token 决定算法族;不同算法族使用类型隔离的密钥对象(RsaSecurityKey 就是 RSA,不会被当成 HMAC 字节)。
3. kid 注入攻击
kid 若被某些实现拿去拼路径(读文件)或拼 SQL(查库),攻击者可注入路径穿越或 SQL 注入,诱导 RP 用一把"攻击者可控的密钥"去验签,甚至读到 /dev/null 之类可预测内容当密钥。
防御 :把 kid 严格当作不可信输入,只用于在受信任的 JWKS 集合里做精确匹配查找,绝不参与文件/数据库的动态拼接。
4. 密钥/JWKS 替换攻击
若 jwks_uri 走的不是 HTTPS,或 RP 不校验 TLS 证书,攻击者可中间人替换整份 JWKS,塞入自己的公钥。
防御 :jwks_uri 强制 HTTPS 并严格校验证书;iss、Discovery 文档、jwks_uri 三者来源一致性校验。
一句话总结防御原则 :验签方掌握主动权------预先固定 Issuer、固定算法白名单、隔离密钥类型、把 Token 头里的一切(alg/kid)都当不可信输入。
九、ASP.NET Core 中的实现要点
下面把上述概念映射到 Microsoft.IdentityModel.Tokens 体系,分签发(IdP)与验证(RP)两侧。
IdP 侧:配置签名凭据并暴露 JWKS
csharp
// 1) 准备签名密钥(生产中私钥应来自密钥库/HSM,而非内存随机)
var rsa = RSA.Create(2048);
var securityKey = new RsaSecurityKey(rsa)
{
KeyId = "2ab-2025-q2" // 这个 KeyId 会写进 Token 头的 kid
};
// 2) 用 RS256 构造签名凭据
var signingCredentials = new SigningCredentials(
securityKey,
SecurityAlgorithms.RsaSha256); // "RS256"
// 3) 签发 ID Token(推荐用较新的 JsonWebTokenHandler)
var handler = new JsonWebTokenHandler();
string idToken = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = "https://idp.example.com",
Audience = "client-app-id",
Claims = new Dictionary<string, object>
{
["sub"] = "248289761001",
["nonce"] = "n-0S6_WzA2Mj",
// at_hash / c_hash 按需计算后加入
},
Expires = DateTime.UtcNow.AddMinutes(10),
SigningCredentials = signingCredentials
});
JWKS 端点则把公钥分量 导出为 JWK 数组发布到 jwks_uri:
csharp
// 把公钥转成 JWK(仅含 n/e 等公开参数,绝不含私钥)
var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(
new RsaSecurityKey(rsa.ExportParameters(includePrivateParameters: false))
);
jwk.KeyId = "2ab-2025-q2";
jwk.Use = "sig";
jwk.Alg = "RS256";
var jwks = new JsonWebKeySet();
jwks.Keys.Add(jwk);
// 序列化后挂到 /.well-known/jwks.json
轮换时,只需在 jwks.Keys 中同时保留新旧两把公钥 ,并按第七节的时间线切换签名所用的 signingCredentials。
RP 侧:验证配置(防御要点都在这里)
csharp
builder.Services.AddAuthentication()
.AddJwtBearer(options =>
{
options.Authority = "https://idp.example.com";
// Authority 会自动驱动 ConfigurationManager:
// 拉取 discovery → jwks_uri → 缓存公钥,并在遇到未知 kid 时自动刷新
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://idp.example.com",
ValidAudience = "client-app-id",
// 关键防御:固定算法白名单,直接堵死 none 与算法混淆
ValidAlgorithms = new[] { "RS256" },
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true, // 拒绝无签名 Token
ClockSkew = TimeSpan.FromSeconds(30)
};
});
落地清单:
ValidAlgorithms显式限定算法------这一行同时挡住了alg: none和 RS256→HS256 混淆。RequireSignedTokens = true杜绝无签名 Token。- 依赖
Authority自动管理 JWKS 缓存与刷新,不要手写脆弱的缓存逻辑。 - 私钥务必托管在密钥库/HSM,代码里不出现私钥常量。
十、签名之外:签名 + 加密(Nested JWT)
签名解决"防篡改、可溯源",但不解决机密性 ------签名后的 Payload 仍是 Base64URL 明文,任何人都能解码看到 sub、email 等声明。如果 ID Token 会经过不可信环节、且 Claims 含敏感信息,就需要在签名之外叠加 JWE(JSON Web Encryption) 加密。
OIDC 规定的顺序是先签后加(sign-then-encrypt),形成嵌套 JWT:
明文 Claims
│ ① JWS 签名(IdP 私钥)
▼
已签名 JWT ────────────── 这就是普通 ID Token
│ ② JWE 加密(RP 公钥)
▼
加密的嵌套 JWT ─────────── 只有 RP 用其私钥能解
验证时反向:RP 先用自己的私钥解密 (JWE),拿到内层已签名 JWT,再用 IdP 公钥验签(JWS)。这样既保证了来源可信(IdP 签名),又保证了内容只有目标 RP 可读(RP 加密)。绝大多数场景只需签名;只有当 ID Token 暴露在高风险链路、且含敏感个人信息时,才上签名+加密的组合。
总结
ID Token 的签名是 OIDC 信任模型的基石,一句话串起来:
IdP 用私钥 对"Base64URL(Header).Base64URL(Payload)"这段精确字节做 JWS 签名 (默认 RS256),把对应公钥 以 JWK 形式发布在
jwks_uri上并用kid标识;RP 通过 Discovery 自动获取公钥,验签时先固定算法白名单、按kid取公钥、验签通过后再校验iss/aud/exp/nonce;借助kid+ JWKS 多键并存,密钥可以零停机轮换。
工程上最该刻进肌肉记忆的三条:验签方永远不信任 Token 自报的 alg、永远把 kid 当不可信输入、永远用类型隔离的密钥对象 。这三条做到位,alg: none 和算法混淆这两类最致命的攻击就基本被关在门外了。