Portfolio
← 返回博客列表
JWT和双token机制

2026-09-03 · 阅读 1

JWT和双token机制

JWT,access token,refrsh token

一次讲透 JWT 与双 Token 机制:从"防伪通行证"到"无感续命"

本文是《一次讲透 XSS 与 CSRF》的续篇。上一篇解决"攻击者怎么偷身份",这一篇解决"身份凭证本身怎么设计"。这两块内容我在面试里被连环追问过——从三段结构一路问到"token 被偷了怎么办"——所以这篇按面试官的追问链路来组织,读完你就能接住整条追问链。

0. 先看 JWT 要取代什么:Session 的三大痛点

在 JWT 之前,主流方案是 Session:

  1. 用户登录,服务器生成 session 存在服务端,发一个 session_id 给浏览器存 Cookie;
  2. 之后每次请求,服务器拿 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,是不是不安全?"

标准接法三步:

  1. 大方承认:payload 是 Base64 编码不是加密,解开就能看——这是设计如此,JWT 保证防篡改不保证保密;
  2. 所以有约定:payload 只放非敏感信息(userId、角色、过期时间),密码这类敏感数据绝不放;
  3. 收尾:userId 本身不是机密,就像用户名本来就是公开的。真要保密用 JWE 加密整条 token,但一般没必要。

3.2 连环炮第二发:"那你拿到完整 token,不就能冒充本人了?"

对——这叫重放攻击(Replay)。注意区分两件事:"看到内容" ≠ "拿到 token"。知道 payload 里有什么没用,但拿到完整三段的人,原样发请求签名 100% 验得过——因为签名只验"token 是不是真的、有没有被改",不验"拿着它的人是不是本人"

钞票比喻:签名 = 防伪水印。水印挡得住假钞(伪造、篡改),挡不住小偷拿你的真钞去花(重放)。

各威胁的防御分工:

威胁谁来防
自己造 token / 改别人的签名(没密钥做不到)
XSS 偷走 tokenHttpOnly(JS 读不到)
中间人截获 tokenHTTPS(传输加密)
真被偷走了短过期时间 + 双 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 TokenRefresh 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 三个"为什么"(面试官必问)

  1. 为什么 access 这么短? 缩小重放攻击的伤害窗口——偷到完整 token 就能重放,唯一的天然止血法就是让它快点死;
  2. 为什么 refresh 不每个请求都带? 最小暴露原则:暴露面决定寿命。它 7 天长命,带的次数越多被截获机会越大,所以只在刷新接口出现一次;
  3. 登出怎么办? 删掉 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》。