JWT 签发实战:refresh 轮换、滑动会话与强制注销

· 约 8 分钟 🪪 JWT 签发

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

绝对上限到了,无论怎么活跃都强制重登——这是身份重新验证的最后一道防线。

时长配置参考

场景accessrefresh绝对上限
银行 / 支付5 min1 天7 天
企业内部15 min7 天30 天
SaaS 工具15 min14 天30 天
消费 App15 min30 天90 天
”7 天免登录” 勾选同上30 天90 天
”永久登录”(不推荐)30 min90 天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 min0 开销普通业务
token_versionpayload.tv vs user.tv即时每请求查 1 次(可缓存)改密码 / 全局强制
jti 黑名单Redis SET,验签后查即时每请求 1 次 Redis细粒度撤销
family revoke + tv bumpsessions.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 入口要显眼——用户自己发现的速度比风控规则快

一份完整加固清单

按顺序自查:

  1. Access JWT,refresh opaque——不要把 refresh 也做成 JWT
  2. Access 短(15 min),refresh 长(7-30 天)——别用 1 天的 access “绕开撤销”
  3. Refresh rotation——单次使用,每次刷新换新
  4. 重放检测——发现 reuse 立即吊销整个 family
  5. Family + sessions 表——按登录会话粒度管理
  6. 滑动 + 绝对上限——30 天闲置过期、90 天强制重登
  7. 多设备视图——用户能看到所有活跃 session 并一键踢
  8. 改密码 → revoke 所有 family + bump token_version——保留当前设备体验
  9. 绑定 + 风控,不是绑定 + 拒——device_id 异常触发二次验证而非拒绝
  10. 业务服务无状态验签——只查 token_version 的本地缓存

把这十条全过一遍,JWT 的”撤销难”和”被盗续命”两个痛点基本就解决了。

JWT 的设计哲学是”用密码学换状态”——你拿走了它的状态,就要承担”撤销难”的代价。Refresh rotation + token family 是把状态加回来的最小代价方式:业务服务保持无状态、auth 服务承担状态、撤销粒度细到 family、性能瓶颈控制在刷新端点。这是 2026 年现代 JWT 实战架构的标准做法。

❓ 常见问题

refresh token 为什么要"轮换"?不轮换有什么问题?

不轮换 = 长 30 天的 refresh 一旦泄露,攻击者可以静默续命 30 天,原用户毫无感知典型攻击场景:(1) 用户在咖啡厅 WiFi 登录;(2) XSS / 中间人 / 浏览器扩展窃取了 refresh token;(3) 攻击者拿着 refresh token 每隔 14 分钟刷新一次 access;(4) 原用户日常使用看不到任何异常;(5) 攻击者悄悄持有访问权限直到 refresh 自然过期。轮换 (refresh rotation) 的核心:(1) refresh token 单次使用——每次刷新都换一根新的;(2) 旧 refresh 立即作废;(3) 重放检测——如果同一 refresh 被使用两次,必定是其中一方持有的是被盗版本→ 全家系吊销,强制重登;(4) 后果:攻击者只要先用了一次,原用户下次刷新就会触发 reuse detection,登录会话立即被掐断、用户被迫重登(这时改密码 + 排查泄露)。实务:(1) 主流 OAuth 库(Auth0、Keycloak、Supabase)都默认开启 refresh rotation;(2) 自研系统应优先实现这一层——比一次性把 refresh 失效时间从 30 天缩到 1 天的"防御"更有效;(3) 轮换 + 短 access(15 min)+ family 撤销 = 现代 JWT 实战的三件套。

"token family" 是什么?怎么用它做重放检测?

Token family 就是"同一次登录派生出来的所有 refresh token 的家谱"数据结构:(1) 用户登录时,服务端发第一根 refresh token(root),生成一个 family_id;(2) 每次刷新都派生新的 refresh,标记 parent_jti = 上一根的 jtifamily_id 不变;(3) 整个登录会话形成一条"链"——root → child1 → child2 → ...。重放检测逻辑:(1) 客户端拿 child2 来刷新;(2) 服务端查到 child2 的 used = true(已经被刷过一次);(3) 这意味着 child3 已经被发出去了——当前拿来用 child2 的人不是它的合法持有者;(4) 立即把整个 family(root → child1 → child2 → child3 → ...)全部标记 revoked;(5) 所有持有这个 family 任意 refresh 的客户端下次刷新都失败 → 强制重登。为什么是"全家"而不是只吊销这一根:(1) 你不知道到底是合法用户还是攻击者持有的是"被盗版本";(2) 安全的假设是两者都不可信——一刀切,强制双方重新登录;(3) 合法用户重登只是 1 分钟麻烦,攻击者重登需要密码,被挡在门外。实务:(1) family_id 用 UUID v4 即可;(2) 数据库表至少三列:jti, family_id, parent_jti, used, revoked, user_id, expires_at;(3) Redis 加速:SET fam:{family_id} revoked=true EX 7d 触发即时全家失效。

滑动会话(sliding session)是什么?怎么避免"用户永远不登出"?

滑动会话 = 用户活跃则自动续期,不活跃才过期两个关键时间窗:(1) inactivity timeout(闲置超时)——多长时间不刷新就强制过期,典型 7-30 天;(2) absolute timeout(绝对上限)——从首次登录起最多续多久,典型 30-90 天。实现:(1) 每根 refresh 有 expires_at = now + 30 天;(2) 用户每次刷新,新 refresh 的 expires_at 又是 now + 30 天——只要活跃就不过期;(3) 同时检查 family.created_at + 90 天 ——超过即使活跃也强制过期。为什么需要绝对上限:(1) 纯"活跃续期"理论上可以永远不重登;(2) 攻击者偷到 refresh 后,可以无限续命——只要每 30 天刷一次;(3) 加 90 天绝对上限——再积极的攻击者也只能续 90 天;(4) 同时强制用户定期重登 = 重新验证身份的最后一道防线。典型配置:(1) 银行 / 支付——access 5 min、refresh 1 天、绝对上限 7 天;(2) 企业内部——access 15 min、refresh 7 天、绝对上限 30 天;(3) 消费 App——access 15 min、refresh 30 天、绝对上限 90 天;(4) "7 天免登录" 选项——用户主动勾选时绝对上限延长到 90 天。

多设备登录怎么管理?用户点"在其他设备退出"怎么实现?

每次登录都开一个独立 family——多设备 = 多 family,按 family 粒度管理数据模型:(1) 用户表 users(user_id, ...);(2) 会话表 sessions(family_id, user_id, device_info, ip, created_at, last_active_at, revoked);(3) refresh 表 refresh_tokens(jti, family_id, parent_jti, used, expires_at)登录新设备:(1) 验证密码;(2) 创建新 family_id 记录到 sessions;(3) 派发首根 refresh + access;(4) device_info 来自 User-Agent 解析。列出活跃设备SELECT * FROM sessions WHERE user_id = ? AND revoked = false——返回所有未撤销 family,前端展示"iPhone 15、Chrome on Mac、..."。单点登出(注销当前设备)UPDATE sessions SET revoked = true WHERE family_id = ?——只影响当前 family,其他设备不动。注销其他所有设备UPDATE sessions SET revoked = true WHERE user_id = ? AND family_id != current——保留当前设备,其余全撤。注销全部设备(改密码后强制)UPDATE sessions SET revoked = true WHERE user_id = ?——所有 family 全撤,全设备强制重登。性能优化:(1) sessions 表加索引 (user_id, revoked);(2) 大用户量时用 Redis 缓存 family revoked 状态;(3) JWT 验签时不查 family 状态(无状态),只在刷新端点查。

改密码 / 强制注销时,已经发出去的 access token 怎么作废?

Access token 是无状态的,无法直接撤销——只能间接处理方案对比:(1) 方案 A:等自然过期 —— access 15 分钟就自动失效,配合 refresh 撤销 = 最多 15 分钟内被盗 token 仍可用;适用于普通业务。(2) 方案 B:token_version 字段 —— 用户表加 token_version 字段,JWT payload 包含 tv: 5;改密码 → version + 1;服务端每次验签后比对 payload.tv === user.tv,不等则拒绝;缺点是每次 API 请求都要查用户表 token_version,性能开销。(3) 方案 C:jti 黑名单 —— Redis 维护已撤销 access jti 集合,验签后查;占用内存 = 已撤销且未过期的 token 数;适合细粒度撤销。(4) 方案 D:family bump —— 用户登录时拿到当前 token_version,绑定到 family;强制注销时 bump version,所有旧 family 失效。实战推荐组合:(1) 普通业务 —— 改密码时 revoke 所有 family + access 自然过期(15 min 窗口可接受);(2) 金融 / 高敏 —— 改密码 + 重要操作时 bump token_version,所有 access 立即失效;(3) 平台级(如 IdP) —— 提供"全局会话表" API,让接入方查;(4) 不要全局 JWT 黑名单 —— 性能差且失去无状态优势。用户视角:改密码 → 当前设备保持登录、其他设备全部失效——通过"保留当前 family + revoke 其他 family + bump token_version" 实现。

refresh token 该存 JWT 还是 opaque token?两者怎么选?

几乎所有场景都应该用 opaque token(不透明字符串)作为 refresh,access 才用 JWT两种 refresh 形式:(1) JWT refresh——把 refresh 也做成 JWT,包含 exp、user_id、family_id 等;优点是可以本地解码;(2) Opaque refresh——随机字符串(如 crypto.randomBytes(32).toString("base64")),服务端数据库查映射;优点是更安全。为什么 refresh 应是 opaque:(1) refresh 不需要分布式验证——只有发放服务(auth service)需要解析它,业务服务不验 refresh;(2) 数据库查询是必须的——因为要查 usedrevoked 状态,无状态 JWT 无法表达"已用",必须查 DB;(3) 数据库查 = 必须查表 = 无所谓 JWT 自描述 —— JWT 的优势完全失去;(4) opaque 更短、更安全——256 位随机串就够,比 JWT 短得多,且没有"我是谁"的信息泄露。access 必须是 JWT:(1) access 在多个业务服务之间流转,每个服务独立验签;(2) 不可能让 10 个微服务都查同一个 auth DB——延迟和耦合都不可接受;(3) 短期 access (15 min) 即使被盗损失也有限——JWT 的"无状态可分布式验证"优势刚好匹配这个场景。结论:(1) access JWT + opaque refresh 是最稳的组合;(2) 已经做了 JWT refresh 不必紧急改——但新项目应直接 opaque;(3) 唯一例外:跨域跨服务的 refresh 端点(少见),有时为了简化把 refresh 也做成 JWT,但应通过 mTLS / VPN 保护。

refresh token 该不该绑定客户端指纹(设备 ID / IP)?防什么、不防什么

适度绑定有用,但不要做强校验——会损失体验且容易绕过典型绑定项:(1) device_id(前端 generate + localStorage / Cookie);(2) IP address;(3) User-Agent;(4) TLS fingerprint(JA3)。适度绑定的价值:(1) refresh 被盗后用在另一台机器——可触发风控告警而非直接拒;(2) 不一致时强制重新登录 / 二次验证;(3) "登录日志"展示"X 设备、Y 地点",用户能识别异常。强校验的问题:(1) IP 不稳定——移动用户切换 4G / WiFi、跨基站、CGNAT 都会变;强校验会让正常用户每次切网络都被踢;(2) device_id 可伪造——前端任何字段都不能 100% 信任;(3) User-Agent 不稳——浏览器升级会变;(4) 代理 / VPN 让真实 IP 失效。实战建议:(1) 绑定 + 风控,不绑定 + 拒:① device_id 不一致 → 邮件告警 + 二次验证(短信 / 邮箱 / TOTP),不直接拒;② IP 地理跨大区(北京 → 美国)→ 提示用户 + 二次验证;③ User-Agent 大类变化(移动 → PC)→ 风控加权但不直接拒;(2) 重要操作上提验证强度——转账、改密码、查敏感信息要求当前会话过 SCA(强客户认证);(3) logout 主动——用户能在"登录设备"页一键踢掉异常 session。反例:(1) ❌ "IP 变了就强制重登"——用户体验灾难;(2) ❌ "device_id 变了拒绝刷新"——一键清缓存就被踢,但攻击者能伪造;(3) ✅ "device_id + IP 跨大区 → 短信验证"——平衡安全和体验。

🪪 打开 JWT 签发 HS/RS/ES/PS 全套·PEM/JWK 私钥·iat/exp 快捷·WebCrypto 本地签名密钥不上传

📖 同一工具的其他教程