JWT 的”无状态” 是双刃剑——签出去的 token 不能直接撤销。多数 JWT 教程教你怎么签发、怎么验签,但很少完整讲过”一次登录到注销” 的工程实现。
这篇按真实生命周期把几个关键组件讲清楚:
- Refresh rotation——单次使用的 refresh,每次刷新都换新
- Token family——把同一次登录派生的所有 token 链起来,便于统一撤销
- 重放检测——同一 refresh 被用两次时怎么办
- 滑动会话——活跃用户自动续期、闲置用户自然过期
- 多设备管理——每台设备一个 family,独立登出
- 强制注销——改密码 / 风控触发时如何让旧 token 立刻失效
整体架构
一次完整的登录会话长这样:
登录
↓
auth server 派发:
access_token (JWT, 15 min, 无状态可验证)
refresh_token (opaque, 30 天, 服务端有状态记录)
↓
业务服务用 access_token 认证(仅验签,不查 DB)
↓
access_token 过期前 1 分钟,客户端用 refresh_token 换新
↓
auth server 收到 refresh:
- 验证 refresh 有效且未用过
- 标记旧 refresh 为 used
- 派发新 access + 新 refresh
↓
循环到用户主动登出或会话超时
关键点:
- access JWT 在业务服务侧无状态验签——快、可分布式
- refresh token 在 auth 服务侧有状态记录——可撤销、可重放检测
- 两者权责分离——业务服务不知道 refresh 的存在
数据模型
最小可工作版本三张表:
-- 用户表(含全局 token version)
users (
user_id PRIMARY KEY,
password_hash,
token_version INT DEFAULT 0, -- 改密码 / 强制注销时 +1
...
)
-- 会话表(每次登录一行)
sessions (
family_id PRIMARY KEY, -- UUID
user_id REFERENCES users,
device_info, -- User-Agent 解析结果
ip_address,
created_at, -- 登录时间(绝对上限基准)
last_active_at, -- 最后一次刷新时间
revoked BOOLEAN DEFAULT FALSE,
expires_at -- 绝对上限 = created_at + 90 天
)
-- Refresh token 表(每次轮换一行)
refresh_tokens (
jti PRIMARY KEY, -- 256-bit 随机字符串
family_id REFERENCES sessions,
parent_jti -- 上一根 refresh(root 为 NULL)
user_id,
used BOOLEAN DEFAULT FALSE,
expires_at,
created_at
)
索引:
sessions(user_id, revoked)—— 列出活跃会话refresh_tokens(family_id)—— family 内 reuse 检测refresh_tokens(expires_at)—— 清理过期数据
Refresh Rotation 流程
核心操作:每次刷新都换根新的,旧的立即作废。
登录
1. 验证用户名 + 密码
2. 创建新 family:
INSERT INTO sessions (family_id = uuid(), user_id, device_info,
created_at = now(),
expires_at = now() + 90 day)
3. 派发首根 refresh:
INSERT INTO refresh_tokens (jti = random(32 bytes),
family_id,
parent_jti = NULL,
user_id,
expires_at = now() + 30 day)
4. 签 access JWT:
{sub: user_id, iat, exp: now+15min, family_id, tv: user.token_version}
5. 返回 {access_token, refresh_token, expires_in: 900}
刷新
POST /refresh { refresh_token }
1. SELECT * FROM refresh_tokens WHERE jti = ?
不存在 / 已过期 → 401
2. used = true → 重放检测触发:
UPDATE sessions SET revoked = TRUE WHERE family_id = ?
返回 401 "token reused, family revoked"
用户必须重新登录
3. SELECT * FROM sessions WHERE family_id = ?
revoked = true → 401
expires_at < now → 401(绝对上限到了)
4. 都通过:
UPDATE refresh_tokens SET used = TRUE WHERE jti = ?
INSERT refresh_tokens (new jti, parent_jti = 旧 jti, family_id, ...)
UPDATE sessions SET last_active_at = now() WHERE family_id = ?
返回 {new access_token, new refresh_token}
注销
POST /logout
UPDATE sessions SET revoked = TRUE WHERE family_id = ?
客户端清 cookie
POST /logout-all-others
UPDATE sessions SET revoked = TRUE
WHERE user_id = ? AND family_id != current
POST /logout-everywhere
UPDATE sessions SET revoked = TRUE WHERE user_id = ?
重放检测:核心防御
场景:用户在咖啡厅 WiFi 登录后被 XSS 偷走 refresh。攻击者拿着 refresh 静默刷新 access,每 14 分钟一次。
没有 rotation 的世界:
- 攻击者持有 refresh,可以续命 30 天
- 用户无感知——直到 refresh 自然过期
- 期间所有数据可被读、可冒用身份
Rotation + 重放检测:
T0: 用户 / 攻击者 都持有 refresh A
T1: 攻击者用 A 刷新 → 拿到 B(A 标记 used)
T2: 原用户也想刷新(用的还是 A)→ 检测到 A 已用
T3: 服务端识别这是 reuse → 整个 family 吊销
T4: 攻击者下次用 B 刷新 → family 已 revoked,401
原用户下次访问 → 强制重登
关键性质:
- 攻击者 每用一次 refresh,就给自己制造一次被发现的机会
- 一旦被发现,双方都被踢出——攻击者继续无门、原用户重登即可
- 不需要识别”哪个是真用户”——一刀切是最稳的策略
Token Family:把 refresh 串成链
一次登录的所有 refresh 形成一棵单向链:
root (jti=A, parent=NULL)
└─ child1 (jti=B, parent=A) [A 已 used]
└─ child2 (jti=C, parent=B) [B 已 used]
└─ child3 (jti=D, parent=C) [C 已 used, D 是当前活跃]
family_id 是这棵链共有的标识符。撤销时一句 SQL:
UPDATE sessions SET revoked = TRUE WHERE family_id = ?
下次任何一根 refresh 来刷新,先查 family.revoked——为真就拒。
性能优化:
- family 状态写入 Redis:
SET fam:{family_id}:revoked 1 EX 7d - 业务服务不查 family(无状态保留)
- 只在 refresh 端点查——QPS 比业务低 1-2 个数量级
滑动会话与绝对上限
闲置超时(inactivity)
每次刷新,新 refresh 的 expires_at = now + 30 天。如果用户连续 30 天没刷新,下次刷新失败、强制重登。
定时任务每天清理:
UPDATE sessions SET revoked = TRUE
WHERE last_active_at < now() - INTERVAL '30 days' AND revoked = FALSE
绝对上限(absolute)
只靠”活跃续期”理论上可以永远不重登。攻击者偷到 refresh 后只要每 30 天刷一次也能续命到底。
加 90 天绝对上限:
-- 登录时
INSERT INTO sessions (... expires_at = now() + 90 day)
-- 刷新时
SELECT * FROM sessions WHERE family_id = ?
IF expires_at < now() THEN
UPDATE sessions SET revoked = TRUE WHERE family_id = ?
RETURN 401
END IF
绝对上限到了,无论怎么活跃都强制重登——这是身份重新验证的最后一道防线。
时长配置参考
| 场景 | access | refresh | 绝对上限 |
|---|---|---|---|
| 银行 / 支付 | 5 min | 1 天 | 7 天 |
| 企业内部 | 15 min | 7 天 | 30 天 |
| SaaS 工具 | 15 min | 14 天 | 30 天 |
| 消费 App | 15 min | 30 天 | 90 天 |
| ”7 天免登录” 勾选 | 同上 | 30 天 | 90 天 |
| ”永久登录”(不推荐) | 30 min | 90 天 | 180 天 |
多设备登录
每次新设备登录 = 新 family。一个用户可以有多个并存的 family:
user_id=42
family-iphone (Safari, iPhone 15, 北京) ← session1
family-chrome (Chrome, MacBook Pro, 北京) ← session2
family-app (iOS App 2.5.0, iPhone 15, 北京) ← session3
列出活跃设备
SELECT family_id, device_info, ip_address, last_active_at
FROM sessions
WHERE user_id = ? AND revoked = FALSE AND expires_at > now()
ORDER BY last_active_at DESC
”在其他设备退出”
UPDATE sessions SET revoked = TRUE
WHERE user_id = ? AND family_id != ? AND revoked = FALSE
保留当前设备,其他全踢。
“改密码后保留当前设备登录”
-- 1. 验证旧密码 + 更新新密码
UPDATE users SET password_hash = ?, token_version = token_version + 1
WHERE user_id = ?
-- 2. 撤销除当前外的所有 family
UPDATE sessions SET revoked = TRUE
WHERE user_id = ? AND family_id != current_family
-- 3. 为当前 family 重新发 access(带新 token_version)
业务服务验签 access 时比对 payload.tv === user.token_version——旧 access 因为 tv 不匹配被拒。
强制注销 access:4 种方案
Refresh 容易撤(查 DB),access 是无状态 JWT 怎么撤?
| 方案 | 实现 | 延迟 | 性能 | 适用 |
|---|---|---|---|---|
| 自然过期 | 不做任何事,等 15 min | 最多 15 min | 0 开销 | 普通业务 |
| token_version | payload.tv vs user.tv | 即时 | 每请求查 1 次(可缓存) | 改密码 / 全局强制 |
| jti 黑名单 | Redis SET,验签后查 | 即时 | 每请求 1 次 Redis | 细粒度撤销 |
| family revoke + tv bump | sessions.revoked + tv | 取决于业务 | 中 | 综合方案 |
实战推荐:
- 普通业务:改密码 → revoke 所有 family + 自然过期(15 min 窗口可接受)
- 金融 / 高敏:改密码 / 重要操作 → bump token_version,所有 access 立刻失效
- 平台级(如 IdP):暴露”会话查询” API 给接入方按需查
不要做的:
- ❌ 全局 jti 黑名单——每次 API 请求都查 Redis,丢失 JWT 无状态优势
- ❌ 在业务服务里查 refresh 表——auth 和业务的职责必须分开
- ❌ 用长 access (1 天+) 绕开撤销难题——被盗损失指数级放大
客户端绑定的边界
把 refresh 绑定到 device_id / IP / User-Agent 听起来安全,但强校验有副作用:
| 字段 | 稳定性 | 强校验副作用 |
|---|---|---|
| IP | 差(移动网络频繁变) | 用户切 WiFi 就被踢 |
| User-Agent | 差(浏览器升级) | 自动更新就被踢 |
| device_id | 中(清缓存就丢) | 一键清缓存就被踢 |
| TLS fingerprint | 中(浏览器版本相关) | 升级浏览器就被踢 |
正确做法是”绑定 + 风控”,不是”绑定 + 拒”:
- device_id 不一致 → 邮件告警 + 短信 / TOTP 二次验证
- IP 地理跨大区 → 提示用户 + 二次验证
- User-Agent 变化 → 风控加权但不直接拒
- 高敏操作(转账、改密码)→ 重新走 SCA
Logout 主动权交给用户:
- “登录设备” 页展示所有活跃 family + 地理 / 时间信息
- 用户能一键踢掉任意 session
- 异常 session 入口要显眼——用户自己发现的速度比风控规则快
一份完整加固清单
按顺序自查:
- ✅ Access JWT,refresh opaque——不要把 refresh 也做成 JWT
- ✅ Access 短(15 min),refresh 长(7-30 天)——别用 1 天的 access “绕开撤销”
- ✅ Refresh rotation——单次使用,每次刷新换新
- ✅ 重放检测——发现 reuse 立即吊销整个 family
- ✅ Family + sessions 表——按登录会话粒度管理
- ✅ 滑动 + 绝对上限——30 天闲置过期、90 天强制重登
- ✅ 多设备视图——用户能看到所有活跃 session 并一键踢
- ✅ 改密码 → revoke 所有 family + bump token_version——保留当前设备体验
- ✅ 绑定 + 风控,不是绑定 + 拒——device_id 异常触发二次验证而非拒绝
- ✅ 业务服务无状态验签——只查 token_version 的本地缓存
把这十条全过一遍,JWT 的”撤销难”和”被盗续命”两个痛点基本就解决了。
JWT 的设计哲学是”用密码学换状态”——你拿走了它的状态,就要承担”撤销难”的代价。Refresh rotation + token family 是把状态加回来的最小代价方式:业务服务保持无状态、auth 服务承担状态、撤销粒度细到 family、性能瓶颈控制在刷新端点。这是 2026 年现代 JWT 实战架构的标准做法。