
2026-09-03 · 阅读 1
JWT和双token机制
一次讲透 JWT 与双 Token 机制:从"防伪通行证"到"无感续命"
本文是《一次讲透 XSS 与 CSRF》的续篇。上一篇解决"攻击者怎么偷身份",这一篇解决"身份凭证本身怎么设计"。这两块内容我在面试里被连环追问过——从三段结构一路问到"token 被偷了怎么办"——所以这篇按面试官的追问链路来组织,读完你就能接住整条追问链。
0. 先看 JWT 要取代什么:Session 的三大痛点
在 JWT 之前,主流方案是 Session:
- 用户登录,服务器生成 session 存在服务端,发一个 session_id 给浏览器存 Cookie;
- 之后每次请求,服务器拿 session_id 查表,查出"你是谁"。
这个模式有三个痛点:
| 痛点 | 具体场景 |
|---|---|
| 存储压力 | 服务器要记住所有在线用户,人一多内存吃紧 |
| 多机部署难 | Session 存在 A 机,请求打到 B 机就查不到——要么粘性会话、要么上 Redis 共享 |
| 跨域/多端不友好 | App、小程序、前后端分离跨域部署时,Cookie 机制很别扭 |
JWT 就是为解决这三个痛点而生的。
1. JWT 是什么:一张自带防伪签名的电子通行证
JWT = 一张自带防伪签名、内容公开可读的电子通行证。 服务器签发后不需要存它,每次请求只需用密钥校验签名,就知道真伪和你是谁。
关键词是无状态:服务器不查表。这一个词同时解决了 Session 的三个痛点。
2. 三段结构:header.payload.signature
拿一个真实 JWT 看,是三段用 . 连接的字符串:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEwfQ.xtQ3a-9m...
| 段 | 内容 | 谁能看 |
|---|---|---|
| header | 声明用什么算法签名 | 公开可见 |
| payload | 用户信息 + 过期时间(userId、角色、exp) | 公开可见 |
| signature | 防伪签名 | 只有持密钥者才能算出 |
Signature 的算法:用密钥对 header + payload 算出的哈希。所以 payload 改一个字,签名就对不上,服务器直接拒绝。
3. 最容易混淆的点:编码 ≠ 加密 ≠ 签名
我曾经的误解是"payload 被第三段保护着,解不开"——错。这三件事必须分家:
| 名字 | 作用 | 谁能逆转 |
|---|---|---|
| Base64Url 编码(payload 用的) | 让数据安全放进 URL | 任何人,在线解码器就行,本来就是明文 |
| Signature 签名 | 证明内容没被改过(防伪) | 没密钥造不出来,但它不是用来"藏"内容的 |
| 加密(JWE,JWT 默认没有) | 藏住内容 | 只有持密钥者能解开 |
一句话:JWT 的设计目标是防篡改,不是防偷看。 第三段不是保险柜的门,是封条——封条不挡你看信的内容,只挡你偷偷换信纸。
3.1 面试连环炮第一发:"payload 能看到 userId,是不是不安全?"
标准接法三步:
- 大方承认:payload 是 Base64 编码不是加密,解开就能看——这是设计如此,JWT 保证防篡改不保证保密;
- 所以有约定:payload 只放非敏感信息(userId、角色、过期时间),密码这类敏感数据绝不放;
- 收尾:userId 本身不是机密,就像用户名本来就是公开的。真要保密用 JWE 加密整条 token,但一般没必要。
3.2 连环炮第二发:"那你拿到完整 token,不就能冒充本人了?"
对——这叫重放攻击(Replay)。注意区分两件事:"看到内容" ≠ "拿到 token"。知道 payload 里有什么没用,但拿到完整三段的人,原样发请求签名 100% 验得过——因为签名只验"token 是不是真的、有没有被改",不验"拿着它的人是不是本人"。
钞票比喻:签名 = 防伪水印。水印挡得住假钞(伪造、篡改),挡不住小偷拿你的真钞去花(重放)。
各威胁的防御分工:
| 威胁 | 谁来防 |
|---|---|
| 自己造 token / 改别人的 | 签名(没密钥做不到) |
| XSS 偷走 token | HttpOnly(JS 读不到) |
| 中间人截获 token | HTTPS(传输加密) |
| 真被偷走了 | 短过期时间 + 双 Token(本文下半场) |
3.3 连环炮第三发:"服务器重启,所有用户的 JWT 还有效吗?"
还有效。 因为 JWT 无状态——服务器根本没存这个 token,验证只依赖两样东西:token 本身 + 密钥。重启后密钥还在环境变量里,签名照样验得过。
对比记忆:Session 存服务端,重启全丢,所有人重新登录;JWT 不存服务端,重启无感。 这也是 JWT 适合分布式/微服务的原因——任何机器拿同一把密钥都能验。
但送命的下半句是它的缺点:
JWT 无法主动失效。 用户点了登出、账号被盗要踢人——token 还没过期,服务器没存它,想踢踢不掉,只能等它自然死亡。
怎么破?往下看。
4. 双 Token 机制:把"干活"和"续命"拆开
4.1 病根:单 Token 的两难
| 过期时间 | 安全性 | 体验 |
|---|---|---|
| 短(15 分钟) | ✅ 被偷了很快自然死亡 | ❌ 用户频繁掉线重新登录 |
| 长(7 天) | ✅ 一周不掉线 | ❌ 被偷 = 灾难 7 天,且无法作废 |
单 token 只有一个寿命旋钮,安全和体验不可兼得——所以才拆成两个,各管一头。
4.2 药方:Access + Refresh
| Access Token | Refresh Token | |
|---|---|---|
| 寿命 | 15 分钟 | 7 天 |
| 职责 | 每个请求都带,证明"我是谁" | 只干一件事:去 /auth/refresh 换新 access |
| 抛头露面程度 | 天天用,暴露面大 | 只在刷新接口出现一次 |
| 被偷了怎样 | 最多再活 15 分钟 | 换新时服务器可校验、可作废 |
类比:Access = 门禁卡,天天刷、容易丢、丢了损失小(反正很快就过期);Refresh = 身份证,锁在 httpOnly 抽屉里,只在补办门禁卡时拿出来。
4.3 完整流程(用户全程无感)
1. 登录成功 → 服务器发两个:access(15min) + refresh(7d,塞进 httpOnly cookie)
2. 之后每个请求带 access
3. access 过期 → 服务器返回 401
4. 前端 axios 拦截器捕获 401 → 自动调 /auth/refresh
(refresh 在 httpOnly cookie 里,浏览器自动带上,JS 碰不到)
5. 服务器验 refresh 有效 → 签发新 access
6. 前端拿新 access 重放刚才失败的那个请求 → 用户无感续命
4.4 三个"为什么"(面试官必问)
- 为什么 access 这么短? 缩小重放攻击的伤害窗口——偷到完整 token 就能重放,唯一的天然止血法就是让它快点死;
- 为什么 refresh 不每个请求都带? 最小暴露原则:暴露面决定寿命。它 7 天长命,带的次数越多被截获机会越大,所以只在刷新接口出现一次;
- 登出怎么办? 删掉 refresh → 攻击者再也没法换新 access → 旧 access 最多再蹦跶 15 分钟自然死亡,之后被永久踢出。伤害窗口从单 token 的 7 天,压到 ≤15 分钟——这叫"准主动失效"。
4.5 进阶:真撤销的代价
Refresh 想做到真撤销:在 Redis 存 refresh 白名单,登出即删。代价是引入了查库——这就是"纯 JWT 无状态 vs 可撤销"的经典权衡。工程上没有银弹,只有取舍。
5. 30 秒面试话术
"单 token 有两难:过期短了体验差,长了被偷无法作废。所以我用双 token:access 15 分钟干活,refresh 7 天只负责续命,放 httpOnly cookie 里不抛头露面。access 过期由前端拦截器静默刷新,用户无感;登出删 refresh,把无法作废的伤害从 7 天压到 15 分钟。想真撤销的话可以在 Redis 存 refresh 白名单,代价是牺牲一点无状态。"
6. 总结记忆卡
- JWT 一句话:自带防伪签名、内容公开的电子通行证,服务器只验签不查表
- 三段结构:header(算法). payload(用户信息,公开). signature(密钥算的哈希,防伪)
- 三分家:编码(人人可逆)≠ 加密(藏内容)≠ 签名(防篡改)
- 重放攻击:签名验真伪,不验持有人——拿到完整 token 就能用
- 重启还有效:优点是分布式友好,缺点是无法主动失效
- 双 Token 一句话:门禁卡短命干活,身份证长命锁抽屉,只在补卡时拿出来
- 登出的本质:删 refresh,把伤害窗口从 7 天压到 15 分钟
本文基于个人项目实战(JWT + GitHub OAuth 双认证博客系统)与面试复盘整理,如有疏漏欢迎指出。上篇见《一次讲透 XSS 与 CSRF》。