解码不等于验证:JWT 生产环境检查清单
JWT 在 2015 年 5 月作为 RFC 7519 发布,建立在 JOSE 规范家族之上(JWS 用于签名、JWE 用于加密、JWA 用于算法名、JWK 用于密钥)。它的设计目标是“尽可能小的、自包含的、签名了的 bearer token”:一个 JSON 对象,签了名,再 Base64url 编成一个 URL 安全字符串。规范文本只有两屏。但它的“枪口”已经填满了整整十年的安全大会议程。
JWT 不是一个认证协议。JWT 不是一种 session。JWT 不是魔法。JWT 是一个字符串格式:三段 Base64url 编码的 JSON,用点拼起来。这就是这个格式的全部。其他东西——refresh token、OAuth 流程、OpenID Connect、密钥轮换——都是别的规范和约定堆在它上面的。
之所以一开始就要重申:野外大多数 JWT bug 都来自团队把这个格式当成了安全机制,而不是把它当成"携带安全声明的序列化格式"。
JWT 长什么样
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzM1Njg5NjAwfQ.signaturesignature
三段,用 . 分隔:
- Header ——
{"alg":"HS256","typ":"JWT"}。签名 / 加密算法和 token 类型。 - Payload —— 声明(claims)。通常是
{"sub":"1234","exp":1735689600,...}。 - 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):算法名注册表(
HS256、RS256、ES256等)。
实践上:后端 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 年大多数后端:优先 ES256 或 EdDSA,胜过 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 放哪儿,决定了攻击面。
HttpOnlycookie —— 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 找到对应密钥,验签。然后校验 iss、aud、exp 与你发出去的 nonce。
JWKS 会定期轮换;库会缓存它一小段 TTL,让密钥轮换不会立刻打断验证。
常见坑
- 信任 token 里的
algheader。 在验证方钉死期望算法,永远不接受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 工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。