← 返回博客
语言: English 中文
Encoding 2026-05-28 10 分钟

解码不等于验证:JWT 生产环境检查清单

JWT 在 2015 年 5 月作为 RFC 7519 发布,建立在 JOSE 规范家族之上(JWS 用于签名、JWE 用于加密、JWA 用于算法名、JWK 用于密钥)。它的设计目标是“尽可能小的、自包含的、签名了的 bearer token”:一个 JSON 对象,签了名,再 Base64url 编成一个 URL 安全字符串。规范文本只有两屏。但它的“枪口”已经填满了整整十年的安全大会议程。

JWTJWSJWERFC 7519OAuthOIDCalg: none

JWT 不是一个认证协议。JWT 不是一种 session。JWT 不是魔法。JWT 是一个字符串格式:三段 Base64url 编码的 JSON,用点拼起来。这就是这个格式的全部。其他东西——refresh token、OAuth 流程、OpenID Connect、密钥轮换——都是别的规范和约定堆在它上面的。

之所以一开始就要重申:野外大多数 JWT bug 都来自团队把这个格式当成了安全机制,而不是把它当成"携带安全声明的序列化格式"。

JWT 长什么样

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzM1Njg5NjAwfQ.signaturesignature

三段,用 . 分隔:

  1. Header —— {"alg":"HS256","typ":"JWT"}。签名 / 加密算法和 token 类型。
  2. Payload —— 声明(claims)。通常是 {"sub":"1234","exp":1735689600,...}
  3. Signature —— 对 header.payload 的密码学签名再编码。

每段都是 Base64url 编码(RFC 4648 §5:用 -_ 替代 +/,无填充)。解码极其简单;用 jq 看一行 shell 就够。这就是这个格式的设计意图:任何参与方不需要联系签发方就能读到内容。

让 token 可信的是签名不验证签名时,JWT 的内容就是用户可控输入,仅此而已。

这是哪一类 token

JWT(RFC 7519)是声明格式。它几乎从不单独出现:

  • JWS(RFC 7515):签名包装。"JWS 签名的 JWT"就是绝大多数人说"JWT"时指代的东西。签了名但没加密,payload 任何拿到 token 的人都能读。
  • JWE(RFC 7516):加密包装。JWE 加密的 JWT 对客户端隐藏 payload。五段而不是三段。用得少。
  • JWK(RFC 7517):密钥的 JSON 格式。
  • JWA(RFC 7518):算法名注册表(HS256RS256ES256 等)。

实践上:后端 API 给客户端签发 token 让对方验证,用的是 JWT-as-JWS。签发方希望 token 对客户端不透明,用 JWT-as-JWE——更常见的做法其实是 opaque token(数据库里建索引的随机字符串)。后者通常才是对的选择,下文展开。

"alg: none" 攻击

2015 年安全研究员 Tim McLean 发表了 "Critical vulnerabilities in JSON Web Token libraries"。最劲爆的发现:JWT 规范允许 alg: none,意为"这条 token 没有签名"。许多库在拿到 alg: none 的 token 时,不验签直接当成 valid 接收

header:  {"alg":"none","typ":"JWT"}
payload: {"sub":"admin","exp":99999999999}
signature: (空)

形状对的 JWT,admin 声明,无签名。有 bug 的库回答:"好,合法。"修复方式直接——验签时显式列出期望的算法白名单——但花了好几年才清理干净。新库偶尔还会回归这条,alg: none 永远不该出现在生产 token 的验证白名单里

同一时代第二类攻击:算法混淆(algorithm confusion)。如果库同时支持 HMAC(HS256)和 RSA(RS256),并且根据 token 自己声明的 alg 来决定用哪个,攻击者只要知道服务器的 RSA 钥(按定义就是公开的),就能用 HMAC、把这个公钥当成 HMAC 共享密钥来签出一个 HS256 的 token。库看到 alg: HS256,就用同一份公钥去做 HMAC 校验——验过——伪造成功。

防御同样:不要让 token 告诉库该用什么算法。验证方必须带外指定期望算法和密钥。

payload 里放什么

规范定义了一小批注册声明,其余都是应用自定义。值得记住的注册声明:

  • iss(issuer,签发方)—— 谁签的。
  • sub(subject,主题)—— token 是关于谁的(通常是用户 ID)。
  • aud(audience,受众)—— token 是给谁的。给 service-A 签的 token 应该被 service-B 拒绝。
  • exp(expiration,过期时间)—— UNIX 时间戳,过了之后 token 失效。
  • nbf(not before,生效时间)—— UNIX 时间戳,之前无效。
  • iat(issued at,签发时间)—— 签发时刻。
  • jti(JWT ID)—— 唯一 token 标识;用于防重放 / 撤销。

最低限度的验证清单:签名合法、exp 没过、nbf 没到未来、iss 是你期望的、aud 包含你。漏任意一项都是 bug。

别在 payload 里放秘密。 payload 是 Base64url,不是加密。"秘密"包括任何你不会打到日志里的东西:全名、邮箱、内部用户 ID、独立受 GDPR / CCPA 保护的内容。如果某项必须保密,用 JWE——或者干脆不放进 token。

对称 vs 非对称:HS vs RS vs ES

签名算法分两族:

  • 对称(HS256、HS384、HS512) —— 共享密钥的 HMAC。签发方和验证方持有同一份密钥。便宜、短。适用于"同一方既签发又验证"(比如同一个后端给自己签 token)。
  • 非对称(RS256/RS384/RS512、PS256/PS384/PS512、ES256/ES384/ES512、EdDSA) —— 公私钥对。签发方用私钥签,任何验证方持公钥。适用于多个独立方需要验证的场景,比如 OIDC。

RS256 是 RSA + PKCS#1 v1.5 padding——安全但不是现代最佳。PS256 是 RSA-PSS,padding 更强。ES256 是 P-256 上的 ECDSA。EdDSA(Ed25519)是最新成员,库支持时是现代推荐。2026 年大多数后端:优先 ES256EdDSA,胜过 RS256;只在"恰好一个签发方一个验证方且是同一服务"时考虑 HS256

alg header 应该始终在验证方白名单里。永远不要接受"token 自己说啥就是啥"。

JWT 适合的场景

为什么用 JWT:不查数据库就能验证。签了名的 JWT 让后端只校验签名就能信任 claims,不需要每条请求都问一次认证服务。这是速度收益。

具体:OIDC id_token、某些流程下的 OAuth access_token、内部服务间调用的 token、跨域联合身份。

JWT 适合的场景

JWT 做 session 的反对论点:签名 token 难以撤销。传统 session 是数据库的一行;删行就立刻撤销。JWT 在 exp 之前一直有效,哪怕用户已经改了密码、被开除、账号被盗、主动登出。要撤销,要么:

  • exp 设得短(5–15 分钟),加上 refresh token 往返——本来想要的"速度"几乎全没了。
  • 在每次请求时查一份撤销 jti 的黑名单——你刚刚把"避免数据库查询"那条路绕回来了。
  • 在用户记录里加一个版本号,每次请求校验——同样问题。

对一个普通的 Web 会话——登录、浏览、登出——服务器端 session + cookie 更简单、更易撤销、带宽更省。2015–2020 年流行的"用 JWT 当 session"模式正在退场,原因正是这个;只在速度收益真的重要、并且能容忍撤销延迟时再选 JWT。

同样的道理也适用于移动端:很多"JWT 做移动端登录"的方案,换成"服务器端 opaque token + 一个短 /me 接口"会更干净。

refresh token

标准 JWT 认证模式:短 access token(JWT,5–15 分钟,每次请求都带)+ 长 refresh token(opaque,只发到 /refresh 端点)。被盗的 access token 很快过期;被盗的 refresh token 可以在服务端撤销。refresh token 不需要是 JWT;做成 opaque 的通常更好,因为你想要服务端撤销能力。

每次使用都轮换 refresh token("rotating refresh tokens"),可以检测和阻止盗用:同一份 refresh token 被用了两次,说明泄露——两份都作废。这是 OAuth 2.1 的建议。

存储:cookie vs localStorage

浏览器把 JWT 放哪儿,决定了攻击面。

  • HttpOnly cookie —— JS 读不到;XSS 注入的脚本偷不走。同站请求自动携带,所以你要操心的是 CSRF——设 SameSite=Lax(或 Strict),状态修改请求校验 origin。这是更安全的默认
  • localStorage / sessionStorage —— JS 能读;XSS 能读。token 暴露给你曾经加载的每一个脚本,包括第三方(统计、广告、厂商 SDK)。高价值 token 别这么放

没有捷径:只要你的应用有任何 XSS 漏洞、并且把 JWT 放在 localStorage 里,攻击者就能偷走 auth token。HttpOnly cookie 下,XSS 仍然能让攻击者以你的身份发请求(cookie 自动带),但他无法把 token 外带去事后用。

真实 JWT 长这样

来自 Google 的 OIDC id_token 大致是:

{
  "iss": "https://accounts.google.com",
  "azp": "client-id-of-the-app.apps.googleusercontent.com",
  "aud": "client-id-of-the-app.apps.googleusercontent.com",
  "sub": "1234567890",
  "email": "alice@example.com",
  "email_verified": true,
  "iat": 1735689000,
  "exp": 1735692600,
  "nonce": "abc123"
}

要验证它,你去拉 Google 的 JWKS(一个列出公钥的 JSON 文档,固定 URL 像 https://www.googleapis.com/oauth2/v3/certs),用 token 的 kid header 找到对应密钥,验签。然后校验 issaudexp 与你发出去的 nonce

JWKS 会定期轮换;库会缓存它一小段 TTL,让密钥轮换不会立刻打断验证。

常见坑

  • 信任 token 里的 alg header。 在验证方钉死期望算法,永远不接受 none
  • 算法混淆(HS/RS)。 用类型严格的验证 API,拒绝意外算法。
  • 根本不验签。 解码很简单,但解出来的内容并不因此可信。
  • 不校验 exp / nbf / aud / iss 四条都是必需的。
  • payload 里塞 PII 或秘密。 payload 是公开可读的。
  • 长生命周期 JWT 当 session。 撤销变痛苦。短 access + opaque refresh 是正确组合。
  • 有 XSS 风险的应用把 JWT 放 localStorage。
  • 不处理时钟漂移。 exp 是墙钟检查,前后留几分钟容差。
  • 跨环境复用密钥。 dev 的签名密钥不该校验 prod 的 token。
  • 源码里硬编码 HMAC secret。 轮换它,从密钥管理器加载。
  • 接受任意签发方。 钉死具体 URL,尤其是联合登录。

选 JWT 还是不选

选 JWT 当:

  • 多个验证方需要在不联系签发方的前提下验 token(微服务、联合身份、OIDC)。
  • 跳过数据库查询带来的性能收益在你这个量级是可观的。
  • token 生命周期短到"无法即时撤销"是可以接受的。

选 opaque token + 服务器 session 当:

  • 想让登出 / 撤销 / 踢人立即生效。
  • 同一服务既签发又消费 token。
  • 你宁可每条请求一次 Redis 查找,也不想要 JWT 这套机制。

诚实总结:JWT 在它被设计的那个用途上很好用(跨信任边界的、无状态的、联合验证),并被过度使用在另一个所有人都伸手去拿它的用途上(浏览器 session)。没把握时,先用 session cookie。当架构真的要求时,再升级到 JWT。

主要参考资料

用于核对本文技术细节的标准与官方文档。

在浏览器里直接解码任意 JWT

本站 JWT 工具在本地解码 header、payload、signature——token 不会离开浏览器。适合在信任之前先看一眼 OIDC id_token 或第三方 API token 里到底有什么。

打开 JWT 工具

相关文章

继续阅读同一主题领域的实践指南。

查看全部文章

Cookie 同意

我们使用 Cookie 来增强您的体验并展示相关广告。您可以自定义您的偏好。