Skip to content
返回博客
安全

JWT 安全最佳实践:攻击手法与防御指南(2026)

保护你的 JWT:阻断 alg:none 与算法混淆攻击,固定算法白名单、轮换密钥、校验 claims、安全存储 token,附面向开发者的 2026 实战清单。

13 分钟阅读

JWT 安全最佳实践:攻击、防御与 2026 实战清单

现代认证大多跑在 JSON Web Token 上,但能让它真正安全的那套做法,被忽略的次数比你想象的多得多。在 OAuth 2.0、OpenID Connect 和微服务的服务间调用里,JWT 已经是事实上的凭证格式。它每年也贡献着一串 CVE,而这些漏洞几乎都能追溯到同一批本可避免的错误:接受未签名的 token、信任攻击者挑选的算法、用了弱签名密钥,或者干脆跳过 claim 校验。

一个 JWT 要算安全,得同时满足四点:签名完好、算法不能被攻击者掉包、claims 确实校验过、token 存在不会被轻易窃取的地方。任何一条破了,你拿到的就是一个认证绕过,而不是一个加固过的 API。下面先讲清楚最关键的三种攻击,再讲防御:怎么选并固定算法、怎么管理密钥、怎么校验 claims、token 该存哪。结尾给一份可以直接贴进代码评审的清单。

JWT 签名到底保护了什么(以及没保护什么)

要理解这些攻击,先得记住一个事实:JWT 是编码,不是加密。一个签名 token 由三段 Base64URL 用点号拼起来,也就是 header.payload.signature。header 和 payload 不过是把 JSON 做了普通的 Base64URL,任何拿到这个 token 的人都能读出里面的每一个 claim。把任意 token 粘进我们的 JWT 解码器,header 和 payload 会以可读 JSON 完整呈现,不需要任何密钥。payload 本来就是公开的,设计如此。

那安全性从哪来?来自签名,也只来自签名。它是用一个密钥(HMAC)或一把私钥(RSA、ECDSA)对 header 和 payload 算出的密码学值。攻击者可以随意读 token,但没有签名密钥,就造不出一个能通过校验的「不同」token。整个信任模型,就建在这一条性质上。

这有两个直接后果。第一,别把密文放进 payload,密码、API 密钥、完整的 PII 都不行,因为任何截获 token 的人都读得到。第二,你的整个安全态势都压在一个步骤上:正确地校验签名。而这正是攻击者下手的地方。想看一份逐段读取 token 的详细演示,参见 如何解码 JWT

三种关键 JWT 攻击,以及逐个的阻断方法

大多数 JWT 漏洞都是同一个套路的变体:服务器信任了某个攻击者能控制的东西。下面三种会直接攻破认证,每种都讲清机理和修复方法。

1. alg:none 攻击:未签名 token 绕过

JWS 规范里有一个值为 nonealg,意思是「未签名」。一个 alg:none 的 token 签名段为空,但末尾仍带着一个点号,形如 header.payload.。攻击很简单:拿一个有效 token,把 header 里的 alg 改成 none,换上你想要的任意 claims(比如 "role": "admin"),再把签名丢掉。早期的 JWT 库默认会接受这种 token,伪造的 token 就一路通过校验。不需要密钥,不需要签名,完整冒充。

想看这样一个 token 长什么样,可以在我们的 JWT 解码器里加载「alg:none」示例,它会弹出一条红色警告,提示该 token 未签名、绝不能用于认证。自己复现一个只要一分钟,这个威胁也就理解透了。

防御办法是在每一次 verify 调用上设一个显式的算法白名单。别让库的默认值来决定哪些算法可以接受,老版本的默认值往往很宽松,而显式声明也不过是多写一个选项。

// WRONG — the library may accept alg:none or any algorithm
jwt.verify(token, key);

// RIGHT — pin the exact algorithm you expect
jwt.verify(token, key, { algorithms: ['RS256'] });

none 绝不该出现在那个数组里。如果你的库没法固定算法,换掉它。

2. 算法混淆:RS256 被降级成 HS256

这是实践中最危险的 JWT 漏洞,2015 年就为人所知,至今仍能在审计里翻出来。它针对的是那些根据 header 里的 alg 字段来决定怎么校验的服务器,而 alg 偏偏是 token 里攻击者能改的那一部分。

机理是这样。你的服务器签发 RS256 token:用一把 RSA 私钥签名,用配对的公钥校验。这把公钥按定义就是公开的,可能挂在你的 JWKS 端点上,也可能躺在你的代码仓库里。攻击者拿到它,把 token 的 header 从 RS256 改成 HS256,然后拿这串公钥字符串当 HMAC 密钥,对伪造的 payload 做 HMAC-SHA256 签名。到了校验这一侧,如果你的代码从 header 读出 alg 并据此选了 HMAC,它就会用那同一把公钥当密钥,对 token 算 HMAC-SHA256。签名对上了,伪造的 token 被接受。

根因是两件事撞在一起:校验方信任了攻击者控制的 alg header,而那把 RSA 公钥又刚好能被攻击者拿去当 HMAC 密钥。单看任何一个都不算 bug。公钥本来就该公开,alg header 本来就该描述 token。漏洞出现在这一刻:你的校验逻辑让那个 header 来决定用哪种密钥类型和算法,于是一个攻击者写入的值,驱动了服务器实际跑的密码学路径。

// WRONG — verification method follows the header's alg field
jwt.verify(token, publicKeyOrSecret);

// RIGHT — hard-code the expected algorithm; never let the header choose
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

显式固定非对称算法(只允许 RS256ES256),把 HMAC 校验和 RSA 校验放在完全分开的代码路径上,并用一个能区分密钥类型、维护良好的库。我们的 JWT 解码器会对任何 HS 系列的 token 打一条公钥混淆警告,正因为这种攻击太常见了,当一个你以为该是非对称的 token 却显示成 HS256,那条警告就是给你的信号。

3. 弱 HMAC 密钥:暴力破解与字典攻击

用 HMAC(HS256/384/512)时,token 的全部安全性就压在一个密钥的熵上。要是这个密钥很短、是个字典词,或者干脆是 secretpassword123 之类的值,那任何捕获到一个有效 token 的攻击者,都能离线把它破出来。像 hashcat 这样的工具每秒能拿数十亿个候选值去碰 token 的签名。密钥一旦失守,攻击者就能签出任何想要的 token,等于拿到了永久有效的 admin 凭证。

这种攻击危险在于它完全离线,悄无声息。攻击者不会去猛敲你的登录端点,所以触发不了任何速率限制,你的日志里也看不出什么。他们捕获一个 token,在自己机器上破解密钥,直到能签出过你每一道检查的 token 才回来。修复没得商量:用至少 32 个来自密码学安全来源的随机字节(256 位),存进密钥管理服务,绝不放在代码或仓库里。

// WRONG — guessable, low entropy, crackable in seconds
const secret = "password123";

// RIGHT — 256 bits from a CSPRNG, then load from KMS at runtime
const secret = require('crypto').randomBytes(32).toString('base64');

想快速搞一个强值,我们的 随机密码生成器能产出适合作 HMAC 密钥的高熵字符串。想亲手感受差别,可以在我们的 JWT 编码器里用一个强密钥签一个测试 token,它完全在浏览器里跑,密钥从不离开你的机器。而一旦校验跨越了信任边界,比如涉及多个服务或第三方校验方,就别再用 HS256,转去用非对称算法,下文细说。

选择并固定正确的算法

算法选得对不对,正是混淆攻击成不成的分水岭,所以这一步要刻意去做。你实际会用到的就三种:

算法类型签名 / 校验密钥适用场景
HS256对称(HMAC)一个共享密钥单一信任边界,同一方既签名又校验
RS256非对称(RSA)私钥签名 / 公钥校验跨服务、第三方校验、JWKS 轮换
ES256非对称(ECDSA)私钥签名 / 公钥校验同 RS256,密钥更小更快,新系统首选

规则很短。同一个信任边界内、签名和校验由同一方完成,HS256 又够用又快。要是除了签名方还有别人需要校验,比如另一个服务、一个合作伙伴或一个公开客户端,那就用非对称算法,并优先选 ES256:同等强度下,它的密钥和签名都比 RSA 小得多。你可以在 JWT 编码器里把 HS256、RS256、ES256 的样例 token 并排签出来,对比它们的结构和签名长度。

不管你选哪个,真正管用的那道防御都一样:在 verify 调用上固定一个显式的算法集合,绝不信任 header 里的 alg 字段。这份白名单是其余一切的地基。

密钥管理与轮换

算法再强也强不过它背后的密钥,而密钥怎么处理,恰恰是大多数指南语焉不详的地方。HS256 的密钥至少 32 个随机字节,放在密钥管理服务里,比如 AWS Secrets Manager、HashiCorp Vault 或 Azure Key Vault。非对称算法的私钥属于 HSM 或 KMS,从不碰应用代码;公钥则对外发布,通常通过一个 JWKS 端点让校验方去拉。

轮换该是例行公事,不是应急处置。给每把密钥在 JWT header 里打一个 kid(key ID),让校验方知道某个 token 是哪把密钥签的。校验这一侧保留一小撮有效密钥,当前密钥加上最近那把旧密钥,这样轮换前刚签出的 token 在有效期内还能通过校验。靠的就是这段重叠期,轮换才平滑,而不是变成一次宕机。

一份简短的密钥清单:

  • 至少每 90 天轮换一次签名密钥,一旦怀疑泄露立即轮换。
  • 通过 JWKS 发布公钥,用 kid 标注版本。
  • 私钥和 HMAC 密钥放在 KMS 或 HSM 里,绝不进 git、绝不进客户端代码、绝不硬编码。
  • 一旦泄露,立刻轮换密钥并吊销所有未过期的 refresh token。

不能跳过的 claim 校验

签名检查证明一个 token 是真的,但证明不了它是「此刻、给你的」。这就是 claim 校验的活,也是你能加上的最便宜的一道防御。每个请求都要检查五个 claim:

  • exp(过期时间):拒绝过期时间已成过去的 token。
  • nbf(生效时间):拒绝在生效窗口还没打开前就拿来用的 token。
  • iat(签发时间):可选地拒绝那些老得不合常理的 token。
  • iss(签发者):确认 token 来自你信任的那个签发者。
  • aud(受众):确认 token 是为你的服务签发的。漏掉 aud 检查是最常见的隐形窟窿,它会让一个为某个 API 签发的 token 被重放去打另一个 API。

大多数库在你传入预期值后会替你校验这些:

jwt.verify(token, key, {
  algorithms: ['ES256'],
  issuer: 'https://auth.example.com',
  audience: 'api.example.com',
  clockTolerance: 5, // seconds, for distributed clock skew
});

允许一点点时钟容差,五秒是个典型值,这样服务器之间的微小偏差就不会把本来有效的 token 拒掉。但别忍不住把它调大,宽松的容差会拉长过期 token 仍能用的那个窗口,而堵上那个窗口正是 exp 存在的意义。想用肉眼核对一个 token 上的 expiat,把它丢进 JWT 解码器,再用我们的 Unix 时间戳转换器把时间戳转出来。

token 生命周期,以及 JWT 存放在哪

服务端的检查只是一半。客户端把 token 存在哪,决定了它有多容易被偷走,而存储正是 XSS 和会话劫持碰头的地方。久经考验的模式是:一个短寿命的 access token(15 到 60 分钟),配一个独立、寿命更长、可吊销的 refresh token。

存哪里,归根到底是一个权衡:

存储位置XSS 暴露CSRF 风险建议
localStorage高:页面上任何 JavaScript 都读得到会话 token 应避免
HttpOnly + Secure + SameSite=Strict cookie低:JavaScript 看不到需要 CSRF 防护会话推荐

放在 localStorage 里的 token,页面上跑的任何脚本都读得到,所以一个 XSS bug 就泄露整个会话,攻击者还能从自己机器上一直重放它,直到它失效。一个 HttpOnly cookie 则根本读不到,这把 XSS 的破坏范围压到攻击者在一个活动页面内能做的事,糟是糟,但不是一份他能带走的凭证。cookie 方案的代价是你现在得做 CSRF 防护,因为 cookie 每个请求都自动捎上;SameSite=Strict 加一个 CSRF token 就能搞定。access token 设得短,泄露时的爆炸半径就小;refresh token 放进一个 HttpOnlySecureSameSite 的 cookie。登出或怀疑被攻破时,在服务端吊销 refresh token 并轮换签名密钥。关于 XSS、CSRF 和安全 cookie 的更多背景,参见我们的 Web 安全最佳实践指南。

JWT 安全清单

在交付任何基于 JWT 的认证之前,把这份清单过一遍:

  • 校验固定了一个显式的算法白名单,并拒绝 alg:none
  • 非对称校验硬编码了预期算法,绝不从 header 读取 alg(阻断混淆)。
  • HS256 密钥至少 32 个随机字节,从 KMS 加载。
  • 私钥存活在 HSM/KMS 里;公钥通过 JWKS 发布并用 kid 标注版本。
  • 签名密钥至少每 90 天轮换一次。
  • 每个请求都校验 expnbfiatissaud,时钟容差不超过 5 秒。
  • access token 存活 15 到 60 分钟;refresh token 放在 HttpOnly cookie 里。
  • payload 里没有任何密文,它是编码,不是加密。

常见问题

JWT 默认就安全吗?

不。JWT 安不安全取决于配置。你得固定算法、拒绝 alg:none、用高熵的密钥或密钥对,并校验 claims。默认或宽松的库配置常常会放行认证绕过。

最危险的 JWT 漏洞是哪个?

算法混淆,也就是 RS256 被降级成 HS256,公钥被当成 HMAC 密钥用。它从 2015 年就为人所知,却至今还在审计里冒头,因为它针对的是那些从 header 的 alg 来挑校验方法的服务器。

我该用 HS256 还是 RS256?

签名和校验由同一方在一个信任边界内完成时,用 HS256。有另一个服务或第三方必须校验,或者你需要 JWKS 轮换时,用 RS256 或 ES256。新系统优先 ES256:同等强度下密钥更小更快。

JWT 应该存在哪里?

会话 token 优先放进一个 HttpOnlySecureSameSite 的 cookie,因为 JavaScript 读不到,单个 XSS bug 也偷不走。会话 token 别用 localStorage,任何 XSS 都会把整个会话泄露出去供人重放。

我应该多久轮换一次 JWT 签名密钥?

例行上至少每 90 天轮换一次,一旦怀疑被攻破就立即轮换。用 kid 给密钥标注版本,并在校验方同时留住当前密钥和最近那把旧密钥,这样轮换前刚签出的 token 还能通过校验。

JWT 能被篡改吗?

没有签名密钥就不能,任何攻击者都伪造不出一个能通过校验的 token。但如果你的服务器接受 alg:none、扛不住算法混淆,或者用了弱密钥,签名就能被绕过。那些都是配置上的失误,不是 JWT 本身的毛病。

我必须校验哪些 claim?

校验 exp(过期时间)、nbf(生效时间)、iat(签发时间)、iss(签发者)、aud(受众)。漏掉 aud 检查是最常见的隐形漏洞,它会让一个本来为某个服务签发的 token 被重放去打另一个服务。

结语

JWT 安全谈不上复杂,但每一层都得守住。签名是你唯一的保证,所以要把它校验对。固定一个显式的算法,绝不信任 header 里的 alg。密钥要强、要定期轮换、要存在 KMS 里。每个请求都校验 expnbfiatissaud。token 存在 XSS 够不到的地方。

想把这些落到实处,把任意 token 粘进我们的 JWT 解码器,看它的算法和 claims,揪出 alg:none 或 HS 混淆的风险,再用 JWT 编码器在浏览器里完整地试一遍签名,你的密钥从不离开设备。

标签: jwt security authentication oauth api-security