登录后由服务器交给你的带签名的令牌,就是 JWT。本文讲解它的原理以及安全使用的关键要点(不会写出攻击手法)。
原理(三个部分)
JWT 是用 . 把 头部.载荷.签名 三部分连接而成的字符串。头部和载荷只是用 base64url 编码(并非加密),只有签名是用密钥(或共享密钥)生成的。
① 头部
签名算法等元信息。任何人都能读
② 载荷
用户 ID、有效期等主张。任何人都能读=不要放机密
③ 签名
用密钥生成。保证未被篡改
内容「只是可读」这一点至关重要。所以载荷不是放密码或个人信息的地方。JWT 的价值在于:服务器通过校验签名,就能确认「这个令牌是我签发的、且未被篡改」。
安全使用的防御措施
必须校验签名,并固定 alg(最重要)
在服务器端每次都校验签名。把允许接受的签名算法(alg)固定为期望的值,并拒绝 alg:none(无签名)。不要轻信令牌头部里写的 alg。
JWT 最佳实践 RFC(RFC 8725)规定:库必须让调用方能够指定所支持的算法集合,并且不得使用其他算法;同时必须校验 JWT 中的所有密码学运算,只要有一项失败就拒绝整个 JWT。
不只拒绝 alg:none,也要阻止算法切换
对 alg 的滥用不只是去掉签名。RFC 8725 描述了这样一种情况:把 RS256 改成 HS256 后,某些库会把 RSA 公钥当作共享密钥,用 HMAC-SHA256 去校验签名(算法混淆)。公钥本来就是公开的,所以一旦这样能通过,攻击者就能自己生成有效的签名。
防御方法相同:不信任头部的 alg,固定服务器期望的算法。先确定算法再校验,而不是根据拿到的密钥材料去选择校验方式。
确认令牌是给谁的、有效到何时
签名正确只能说明令牌是真的。它是否发给你、是否仍在有效期内,需要另外检查。
RFC 7519 规定:对于 aud,「当存在该声明时,如果处理方不能用 'aud' 中的某个值标识自己,则必须拒绝该 JWT」;exp 表示「在该时间及之后不得接受该 JWT」(nbf 则在另一端以相同方式起作用)。RFC 8725 还要求应用确认所用的密钥属于该签发者。如果跳过这些检查,原本合法签发给其他服务的令牌就可能被你的服务接受。
不要把机密放进载荷
要以"内容人人都能解码"为前提。密码、个人信息、API 密钥等机密不要放进去。只放标识符或权限等即便被看到也不致命的主张。
把有效期设短,并准备失效手段
访问用令牌要设为短命。过长的有效期会延长令牌被盗后的受害时间。如果需要失效,就把短命的访问令牌+服务器端管理的刷新/会话搭配使用。
使用强密钥,并存放在不会被盗的地方
签名密钥要足够强,不要复用,并保管在服务器端的安全位置。令牌本身也要守护好,比如不被 XSS 窃取、用带安全属性的 Cookie 传输(→ 注意经由 XSS 的窃取)。
本站观点:JWT 并不是适用于所有场景的会话机制
JWT 常被用于「保存登录状态」,但它有一个弱点:不擅长失效。只要签名正确且在有效期内,服务器基本上就会信任它,因此很难让登出或失效立即生效。
本站的立场是:不要把 JWT 当作长期有效的会话替代品。把短命的访问令牌,与服务器端可撤销的刷新/会话组合起来,就能兼顾 JWT 的优点(校验轻量)和失效的便利性。
注意:「能解码」不等于「正确」
JWT 的内容是可读的,所以只要贴进 JWT 解码器,任何人都能查看头部和载荷。但是解码(读取内容)与校验(确认真伪)完全是两回事。能解码并不能保证任何正确性。
判定令牌真伪的,永远是服务器端的签名校验。调试时查看内容很方便,但请不要误以为「能读出来就有效」。
JWT 与服务器端会话(Cookie)的区别
JWT 只是保存登录状态的方式之一。与服务器端会话相比,它在有些场景更合适,在另一些场景则不如后者。
| 项目 | 服务器端会话(Cookie) | JWT |
|---|---|---|
| 状态保存在哪里 | 服务器上(数据库或内存);Cookie 里只有 ID | 令牌内部;服务器只校验签名 |
| 登出 / 停用账号 | 在服务器上删除即可立即生效 | 除非另行实现撤销,否则在有效期内都会被接受 |
| 内容是否可见 | 只有 ID,没有可读内容 | 任何人都能解码读取 |
| 适合的场景 | 运行在单一域名上的普通 Web 应用 | 多个服务或 API 校验同一个令牌 |
对于只在一个域名上运行的 Web 应用,服务器端会话往往就够用了。如果选择 JWT,登录时签发新令牌,与防御会话固定是同一个原则。
真实案例:未检查算法的库
算法切换并不只是理论。被广泛使用的 Node.js JWT 库 jsonwebtoken 在 4.2.1 及更早版本中,没有正确检查令牌头部指定的算法,导致校验可以被绕过(CVE-2015-9235,已在 4.2.2 修复)。本应使用公钥校验的令牌,改用共享密钥(HS 系列)算法签名后也能通过——这正是上文所说的算法混淆。
教训有两点:
- 保持库为最新版本。把锁文件贴进依赖漏洞扫描器,就能检查依赖中是否有已知漏洞
- 不要依赖库的默认设置,在自己的代码中明确写出允许的算法
如何检查自己的令牌
把自家服务签发的令牌贴进 JWT 解码器,查看以下几点。解码在浏览器中完成,令牌不会被发送到服务器。即便如此,如果是生产环境中正在使用的令牌,检查后最好登出一次,让它失效。
- 头部的
alg是否为期望的值(不是none)? - 是否有
exp,从iat(签发时间)算起的有效时长是否符合用途? - 是否有
aud和iss,且指向自家服务? - 载荷中是否包含不希望被看到的内容,例如邮箱地址、电话号码、API 密钥?
服务器端的校验请在开发环境中用测试确认。改动了一个签名字符的令牌、alg 不同的令牌、已过期的令牌、发给其他服务的令牌(aud 不符)都应被拒绝——建议把这些写成自动化测试。
出处(一手资料)
- IETF RFC 8725, "JSON Web Token Best Current Practices" — datatracker.ietf.org(固定算法、校验所有密码学运算、算法混淆、密钥须属于签发者)
- IETF RFC 7519, "JSON Web Token (JWT)" — datatracker.ietf.org(
exp、nbf、aud的规范定义) - GitHub Advisory Database, "Verification Bypass in jsonwebtoken" (CVE-2015-9235) — github.com/advisories(受影响版本为 4.2.1 及更早,已在 4.2.2 修复)
- OWASP HTML5 Security Cheat Sheet — cheatsheetseries.owasp.org(不要把会话标识符存放在 localStorage 中)
接下来阅读
- 工具:JWT 解码器 / 检查(查看内容、检查 alg:none 或是否过期)
- 设计:密码重置的设计缺陷(签发令牌一侧的常见错误)
- 术语:XSS 是什么(窃取令牌的典型路径)/ CSRF 是什么 / 会话固定是什么 / 公钥密码是什么
- 入门:如何选择双因素认证(MFA)