DevKitLab Logo DevKitLab
JWT / 安全 / 身份认证 / 调试

我的 JWT 安全吗?验签是怎么被绕过的

许多 JWT 安全事故不是密码学被破解,而是验证器被错误配置:它接受了攻击者写的 token,或根本没有执行验证。根源在于让未经验证的 token 影响代码如何校验它。本文拆解主要攻击路径,以及能阻止它们的验证规则。

你已经验过签,所以觉得安全。这个方向没有错,但“验过签”和“正确地完成验证”并不是一回事。许多 JWT 安全事故,正是出在两者之间的差别上。

一个关键事实是:许多 JWT 安全事故并不是密码学原语被攻破。 攻击者通常利用验证器配置、密钥处理或应用逻辑的错误,而不是逆向 SHA-256 或分解 RSA 密钥。他们的目标是让你的代码把攻击者构造的 token 判为“有效”。

正如第一篇所述,在签名通过验证前,token 的 header 和 payload 都是攻击者可控的输入。header 还可能包含 algkid 等会影响验证流程的字段。验证器若把这些未验证字段当成指令,而不是待核对的输入,就可能做出错误的信任判断。签名一旦验证通过,受保护的 header 也会受到签名保护;风险只发生在验证之前。

因此,不要只问“JWT 本身是否安全”。更重要的是:我的验证器是否把本应由服务端决定的事情交给了 token?下文会逐一说明常见的绕过方式。它们的共同点是,token 影响了验证器本该自行决定的事项。相应的防御原则也很简单:把 token 作为待验证的数据,而不是需要执行的指令。

核心问题:验证器为什么会接受不该接受的 token

先回到上一篇的两个问题:token 写了什么?我能否信任它?前者可以直接读取;后者必须经过验证。本文的攻击都针对第二个问题,常见手法有五类:

  1. 跳过检查:让验证器得出“没有内容需要验证”的结论。
  2. 用错密钥:让它使用攻击者也能取得的密钥。
  3. 操控密钥选择:让它取得或选择攻击者控制的密钥。
  4. 猜测密钥:当密钥太弱时,无须绕过验证流程。
  5. 把合法 token 用在错误的服务上:token 真实有效,但不是签发给当前服务的。

其中四类不需要削弱签名算法本身,而是绕过或误导验证流程。还有一个更基础的问题:验证根本没有发生。

最基础的错误:根本没有验签

最常见、也最容易遗漏的问题,是验证函数根本没有被调用。因为在代码里,“读取 token”和“验证 token”看起来很相似。许多库分别提供解码和验证 API:解码会读取 claim,并可能返回完整 payload,但不会认证它;只有验证才会用预期密钥和规则检查签名。若开发者直接从解码结果中读取 user.role,测试可能全部通过,却没有任何地方提示签名验证被跳过了。此时攻击者不必伪造签名,只需修改 payload。

这正是上一篇文章最重要的结论:解码不等于信任。 排查 JWT 风险时,应先确认应用是否真正调用了验证函数、是否提供了正确的密钥,以及是否根据验证结果决定后续逻辑。把任意 token 粘贴到 JWT 解析器中,即使不提供密钥,工具也能将它解码为可读 JSON;但状态会保持在 “Decoded, not verified”(已解码,未验证)。解码并不等同于信任,这也是后端必须守住的边界。

攻击一:通过 alg: none 跳过验证

JWA 规范定义了“未加保护”的 JWS:其 algnone,签名为空。它只适用于完整性由其他协议层保证的特殊场景。若验证器直接依照 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 密钥生成器生成一对密钥来理解这种区别:公钥可以公开分发,因此绝不能被当作对称密钥使用。

攻击三:不安全的密钥查找:kidjkux5u

前两类攻击让验证器误用已有密钥。这一类攻击则试图影响验证器使用哪一把密钥。

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 的安全性取决于共享密钥;secretpasswordchangeme 或应用名称之类的密钥很容易被猜到。攻击者只要取得一个有效 token,就能在离线环境中用字典反复计算 HMAC,直到匹配为止。没有服务器端限流或锁定。密钥一旦泄露或被猜中,攻击者便可签发任意 token,因为 HMAC 的验证密钥也是签名密钥。

长度和熵不能混为一谈。RFC 7518 要求 HMAC 密钥至少与哈希输出一样长,HS256 即 256 位;但人工输入的长密钥仍可能容易猜测,因为其熵远低于表面长度。应使用 CSPRNG 生成至少 32 个随机字节,按库要求编码后作为受管密钥保存,绝不写入源码。密码生成器使用 Web Crypto 随机性,可用于开发或测试中的高熵字符串;生产密钥应遵循平台认可的密钥管理流程。

还应考虑谁持有密钥。当验证方不应具备签发能力,或验证能力跨越不完全受控的信任边界时,优先使用公钥签名(RS/ES/EdDSA),这样验证材料泄露也不能伪造 token。如需确认候选密钥能否验证某个 token,可将两者粘贴到 JWT 解析器;显示 “Signature verified”(签名已验证) 仅说明该密钥能验证该 token。这是调试功能,不应在未经批准的环境中测试生产密钥。

攻击五:缺少受众、发行方或类型校验

这一类问题不是伪造:token 的签名有效且未过期,却不应被当前服务接受。

有效签名只证明两件事:token 在签名后未被篡改,且签名可由预期密钥验证。它不单独说明调用者是谁,也不说明其权限。身份和授权还取决于 isssubaud 等 claim、token 类型和服务端规则。若验签后跳过 audiss,就可能出现混淆代理(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,并拒绝包含无法识别扩展的 crit header。规范要求这样做,避免 token 要求执行服务端不支持的处理。
  • 先验签,再校验 claim。 签名通过后,校验 expnbf(允许适度时钟偏差),再精确校验 issaud、token 的 typ 及端点需要的其他 claim。
  • 先认证,再逐请求授权。 验签通过只代表身份已认证,不代表请求已获授权。应在服务器端对每个请求执行权限检查。需要立即生效的授权变更,例如角色撤销或账户停用,应查询实时状态,而不是只依赖 token 中的 role claim。

有些问题源自库的 CVE,另一些源自应用配置和密钥查找代码。应使用维护良好的 JWT 库并及时更新,而不是手写验签;同时限制可接受算法、密钥来源和 token 大小。攻击面越小,后续缺陷可利用的空间就越少。

生产环境加固清单

这些攻击的共同点是,token 影响了本应由服务端代码决定的事项。可以归纳为以下八项:

  1. 调用验证 API。 提供正确密钥,并只根据验证结果执行后续逻辑。decode 不是 verify,“能解码”不是安全检查。
  2. 限制算法。 读取 header 的 alg 只用于与服务端允许列表比较;每把密钥只能用于一种算法。拒绝 none 和所有未允许算法。
  3. 不要让 token 决定密钥来源。kid 视为预配置密钥或固定 JWKS 中的不透明索引。除非已限制可信主机、要求 HTTPS 与证书验证、且请求不带凭据,否则忽略 jkux5u
  4. 非对称公钥仅用于验证。 密钥与算法的绑定可防止 RSA 公钥被误用为 HMAC 密钥。
  5. 使用高熵密钥。 至少使用 32 个来自 CSPRNG 的随机字节,并作为受管密钥保存。验证方不应具备签发能力时,优先使用公钥签名。
  6. 精确校验 claim。 验签后检查 expnbfissaud 与 token typ。即使签名有效,签发给其他服务或类型不符的 token 也必须拒绝。
  7. 认证不等于授权。 在服务器端对每个请求执行权限检查。需要即时撤销的权限应查询实时状态。
  8. 假设 token 可能泄露。 使用短时效 token,以服务端状态支持 jti 重放检测,并在高价值 API 中考虑发送方绑定 token。被盗但仍有效的 token 依然能通过验签。

还应记住第一篇的原则:payload 对任何人都可读,所以不要存放机密信息。对于必须立即变更或撤销的权限,不要只依赖长时效 token 的 claim;风险较高时,应使用短时效、撤销机制或在线授权检查。

这并不要求你比密码学家更聪明。关键是让验证器根据自身配置决定是否验证、可接受的算法和密钥,以及密钥来源;token 只是需要核验的证据。这样可以消除主要的验证绕过路径,但 token 被盗、权限控制和密钥运维仍需要各自的防线。

但验证无法解决一个问题:真实、有效的 token 落入攻击者手中。接下来的两篇会分头展开——先讲 token 该存在浏览器的哪里localStorage、cookie,以及 XSS 与 CSRF 之间的取舍),再讲 短时效 access token、refresh token 轮换与撤销。签名证明 token 未被篡改;生命周期管理用于限制真实 token 被盗后的损失。