我的 JWT 安全吗?验签是怎么被绕过的
许多 JWT 安全事故不是密码学被破解,而是验证器被错误配置:它接受了攻击者写的 token,或根本没有执行验证。根源在于让未经验证的 token 影响代码如何校验它。本文拆解主要攻击路径,以及能阻止它们的验证规则。
你已经验过签,所以觉得安全。这个方向没有错,但“验过签”和“正确地完成验证”并不是一回事。许多 JWT 安全事故,正是出在两者之间的差别上。
一个关键事实是:许多 JWT 安全事故并不是密码学原语被攻破。 攻击者通常利用验证器配置、密钥处理或应用逻辑的错误,而不是逆向 SHA-256 或分解 RSA 密钥。他们的目标是让你的代码把攻击者构造的 token 判为“有效”。
正如第一篇所述,在签名通过验证前,token 的 header 和 payload 都是攻击者可控的输入。header 还可能包含 alg、kid 等会影响验证流程的字段。验证器若把这些未验证字段当成指令,而不是待核对的输入,就可能做出错误的信任判断。签名一旦验证通过,受保护的 header 也会受到签名保护;风险只发生在验证之前。
因此,不要只问“JWT 本身是否安全”。更重要的是:我的验证器是否把本应由服务端决定的事情交给了 token?下文会逐一说明常见的绕过方式。它们的共同点是,token 影响了验证器本该自行决定的事项。相应的防御原则也很简单:把 token 作为待验证的数据,而不是需要执行的指令。
核心问题:验证器为什么会接受不该接受的 token
先回到上一篇的两个问题:token 写了什么?我能否信任它?前者可以直接读取;后者必须经过验证。本文的攻击都针对第二个问题,常见手法有五类:
- 跳过检查:让验证器得出“没有内容需要验证”的结论。
- 用错密钥:让它使用攻击者也能取得的密钥。
- 操控密钥选择:让它取得或选择攻击者控制的密钥。
- 猜测密钥:当密钥太弱时,无须绕过验证流程。
- 把合法 token 用在错误的服务上:token 真实有效,但不是签发给当前服务的。
其中四类不需要削弱签名算法本身,而是绕过或误导验证流程。还有一个更基础的问题:验证根本没有发生。
最基础的错误:根本没有验签
最常见、也最容易遗漏的问题,是验证函数根本没有被调用。因为在代码里,“读取 token”和“验证 token”看起来很相似。许多库分别提供解码和验证 API:解码会读取 claim,并可能返回完整 payload,但不会认证它;只有验证才会用预期密钥和规则检查签名。若开发者直接从解码结果中读取 user.role,测试可能全部通过,却没有任何地方提示签名验证被跳过了。此时攻击者不必伪造签名,只需修改 payload。
这正是上一篇文章最重要的结论:解码不等于信任。 排查 JWT 风险时,应先确认应用是否真正调用了验证函数、是否提供了正确的密钥,以及是否根据验证结果决定后续逻辑。把任意 token 粘贴到 JWT 解析器中,即使不提供密钥,工具也能将它解码为可读 JSON;但状态会保持在 “Decoded, not verified”(已解码,未验证)。解码并不等同于信任,这也是后端必须守住的边界。
攻击一:通过 alg: none 跳过验证
JWA 规范定义了“未加保护”的 JWS:其 alg 为 none,签名为空。它只适用于完整性由其他协议层保证的特殊场景。若验证器直接依照 header 的 alg 选择验证方式,却不与服务端允许列表和密钥类型核对,攻击者就可把 alg 改为 none、移除签名,并构造任意 payload。这样的验证器会把“没有签名”误认为“无需验证”,从而接受伪造 token。
现代合规库默认拒绝未加保护的 JWS,除非应用显式允许。因此,实际风险通常来自旧依赖、隐藏算法配置的封装,或错误地关闭了保护措施。不要把“没有报错”当成“已经安全验证”。
正确做法是:验证器可以读取未验证 header 中的 alg,但只能把它作为待比较的输入。它必须与服务端配置的允许算法和密钥类型一致,才可继续验证;否则立即拒绝,包括 none。这样,未加保护的 token 会在使用签名之前被拒绝。本站的 JWT 解析器不提供 none 验证模式,也体现了这一原则。
攻击二:算法与密钥类型混淆
RS256 与 HS256 的混淆,会把公开的 RSA 公钥误当成 HMAC 密钥。
在 RS256 中,签发方用 RSA 私钥签名,服务用对应的公钥验证。公钥本来就可以公开分发。若验证器允许 token 的 alg 决定算法,攻击者就能把 header 中的 RS256 改为 HS256,再把公开的 RSA 公钥字节当作 HMAC 密钥计算 MAC。若服务端也错误地用同一公钥执行 HMAC,MAC 就会匹配,伪造 token 将被接受。
根因仍然是算法选择与密钥类型没有由服务端固定。应在配置中限定可接受算法,并将每把密钥绑定到唯一的算法用途。例如,RSA 公钥只能用于 RS256 验签,绝不能当作 HS256 的 HMAC 密钥。现代库通常要求显式提供允许算法列表;若所用库或封装仍从 header 推断算法,这一行为本身就是风险。可用 RSA 密钥生成器生成一对密钥来理解这种区别:公钥可以公开分发,因此绝不能被当作对称密钥使用。
攻击三:不安全的密钥查找:kid、jku 与 x5u
前两类攻击让验证器误用已有密钥。这一类攻击则试图影响验证器使用哪一把密钥。
kid(key ID) 用于从一组密钥中定位候选密钥,但在验签前它只是攻击者可控的文本。若把它直接拼进文件路径、SQL 或 LDAP 查询,就可能引入路径穿越或注入。正确做法是只把它用作固定、受信任 key set 或缓存 JWKS 中的不透明索引;未知的 kid 应拒绝,而不是用于拼接路径、查询或 URL。
jku(JWK Set URL) 与 x5u(X.509 URL) 可以引用远程密钥。支持这些字段本身不必然有问题,问题在于盲从 token 提供的任意 URL。多数服务应忽略它们,只使用预先配置的 issuer metadata。确需使用时,必须限制可信主机、要求 HTTPS 与证书验证,并在请求中不携带 cookie 或凭据。排查可疑 token 时,可在 JWT 解析器中查看这些字段;工具只显示 header,不会请求其中的 URL。若 token 引用 X.509 证书,应使用证书解析器读取证书主体和有效期,而不是直接信任 header 指向的位置。
攻击四:弱 HMAC 密钥
有时不存在绕过技巧,问题就是 HMAC 密钥太弱。HS256 的安全性取决于共享密钥;secret、password、changeme 或应用名称之类的密钥很容易被猜到。攻击者只要取得一个有效 token,就能在离线环境中用字典反复计算 HMAC,直到匹配为止。没有服务器端限流或锁定。密钥一旦泄露或被猜中,攻击者便可签发任意 token,因为 HMAC 的验证密钥也是签名密钥。
长度和熵不能混为一谈。RFC 7518 要求 HMAC 密钥至少与哈希输出一样长,HS256 即 256 位;但人工输入的长密钥仍可能容易猜测,因为其熵远低于表面长度。应使用 CSPRNG 生成至少 32 个随机字节,按库要求编码后作为受管密钥保存,绝不写入源码。密码生成器使用 Web Crypto 随机性,可用于开发或测试中的高熵字符串;生产密钥应遵循平台认可的密钥管理流程。
还应考虑谁持有密钥。当验证方不应具备签发能力,或验证能力跨越不完全受控的信任边界时,优先使用公钥签名(RS/ES/EdDSA),这样验证材料泄露也不能伪造 token。如需确认候选密钥能否验证某个 token,可将两者粘贴到 JWT 解析器;显示 “Signature verified”(签名已验证) 仅说明该密钥能验证该 token。这是调试功能,不应在未经批准的环境中测试生产密钥。
攻击五:缺少受众、发行方或类型校验
这一类问题不是伪造:token 的签名有效且未过期,却不应被当前服务接受。
有效签名只证明两件事:token 在签名后未被篡改,且签名可由预期密钥验证。它不单独说明调用者是谁,也不说明其权限。身份和授权还取决于 iss、sub、aud 等 claim、token 类型和服务端规则。若验签后跳过 aud 和 iss,就可能出现混淆代理(confused deputy):用户为服务 A 合法取得的 token 被重放到服务 B,后者因签名有效而接受它。因此,必须在验签后精确比较 aud 与可信 iss;不要用“以……开头”或宽松正则来比较受众。
上一篇的具体场景是:OIDC ID token 被提交给只应接受 access token 的 API。仅凭 aud 不一定能区分两者,因此还要检查 token 类型。遵循 RFC 9068 的 JWT access token 使用 at+jwt;其他场景应定义并强制自己的类型或 profile。无论如何,要求 access token 的位置不能接受 ID token,反之亦然。不同凭据应使用互斥的验证规则,而不应只依赖受众区分。
被盗 token 也无需伪造。验证器无法区分合法持有者与从日志或被攻陷设备中复制 token 的攻击者;在有效期内,两者都能通过验证。jti(token ID)本身不能阻止重放,只有配合服务端状态——例如每次使用时原子检查已消费或已撤销 ID——才有作用。发送方绑定 token(sender-constrained token) 如 DPoP 或双向 TLS,可通过 cnf 将 token 绑定到客户端密钥。它不能让被盗 token 自动失效,但在客户端私钥仍受保护时,能降低单独重放 bearer token 的价值。短时效和撤销机制同样重要。
建立可信的验证流程
与其逐项打补丁,不如建立一条从头到尾由服务端配置做决定的验证流程:
- 密钥由配置提供,而非 token。 预先配置每个签发方的签名密钥:可信 issuer metadata 中固定的 JWKS URI,或服务直接持有的密钥。header 中的
kid只能在这些密钥中选择。遇到未知kid时可刷新一次 JWKS;仍找不到就拒绝。密钥轮换失败应采用失败即拒绝的策略。 - 限制算法,并绑定密钥用途。 只接受服务端配置的算法,并确保每类算法只使用对应的密钥类型。这些规则应在任何签名运算前生效。
- 限制输入。 拒绝过大的 token,并拒绝包含无法识别扩展的
critheader。规范要求这样做,避免 token 要求执行服务端不支持的处理。 - 先验签,再校验 claim。 签名通过后,校验
exp与nbf(允许适度时钟偏差),再精确校验iss、aud、token 的typ及端点需要的其他 claim。 - 先认证,再逐请求授权。 验签通过只代表身份已认证,不代表请求已获授权。应在服务器端对每个请求执行权限检查。需要立即生效的授权变更,例如角色撤销或账户停用,应查询实时状态,而不是只依赖 token 中的
roleclaim。
有些问题源自库的 CVE,另一些源自应用配置和密钥查找代码。应使用维护良好的 JWT 库并及时更新,而不是手写验签;同时限制可接受算法、密钥来源和 token 大小。攻击面越小,后续缺陷可利用的空间就越少。
生产环境加固清单
这些攻击的共同点是,token 影响了本应由服务端代码决定的事项。可以归纳为以下八项:
- 调用验证 API。 提供正确密钥,并只根据验证结果执行后续逻辑。
decode不是verify,“能解码”不是安全检查。 - 限制算法。 读取 header 的
alg只用于与服务端允许列表比较;每把密钥只能用于一种算法。拒绝none和所有未允许算法。 - 不要让 token 决定密钥来源。 将
kid视为预配置密钥或固定 JWKS 中的不透明索引。除非已限制可信主机、要求 HTTPS 与证书验证、且请求不带凭据,否则忽略jku与x5u。 - 非对称公钥仅用于验证。 密钥与算法的绑定可防止 RSA 公钥被误用为 HMAC 密钥。
- 使用高熵密钥。 至少使用 32 个来自 CSPRNG 的随机字节,并作为受管密钥保存。验证方不应具备签发能力时,优先使用公钥签名。
- 精确校验 claim。 验签后检查
exp、nbf、iss、aud与 tokentyp。即使签名有效,签发给其他服务或类型不符的 token 也必须拒绝。 - 认证不等于授权。 在服务器端对每个请求执行权限检查。需要即时撤销的权限应查询实时状态。
- 假设 token 可能泄露。 使用短时效 token,以服务端状态支持
jti重放检测,并在高价值 API 中考虑发送方绑定 token。被盗但仍有效的 token 依然能通过验签。
还应记住第一篇的原则:payload 对任何人都可读,所以不要存放机密信息。对于必须立即变更或撤销的权限,不要只依赖长时效 token 的 claim;风险较高时,应使用短时效、撤销机制或在线授权检查。
这并不要求你比密码学家更聪明。关键是让验证器根据自身配置决定是否验证、可接受的算法和密钥,以及密钥来源;token 只是需要核验的证据。这样可以消除主要的验证绕过路径,但 token 被盗、权限控制和密钥运维仍需要各自的防线。
但验证无法解决一个问题:真实、有效的 token 落入攻击者手中。接下来的两篇会分头展开——先讲 token 该存在浏览器的哪里(localStorage、cookie,以及 XSS 与 CSRF 之间的取舍),再讲 短时效 access token、refresh token 轮换与撤销。签名证明 token 未被篡改;生命周期管理用于限制真实 token 被盗后的损失。