一、为什么需要 Token 刷新机制
在分布式系统架构下,用户认证授权面临三大核心挑战:安全性(防止凭证泄露)、时效性(平衡安全与用户体验)、可扩展性(支持多端接入)。
传统 Session 机制把身份存在服务端,登录后服务端存一份记录(Session),每次请求带 Cookie 去查——这在分布式、高并发场景下暴露出 Redis 连接池被占满、Session 复制延迟、边缘节点无状态等“中心化瓶颈”。JWT(JSON Web Token)的出现,把用户 ID、角色、时效、签名封装成一串自包含字符串,服务端无需存储,只需验证签名即可。这是“无状态浪潮”的核心,但它也引入了新的问题:无法主动吊销、无法主动踢人、无法主动续期。
其中,“无法主动续期”这个问题,就催生了 Token 刷新机制。以下系统介绍单 Token 和双 Token 两种主流方案,并对 JWT 的底层逻辑做深入剖析。
二、单 Token 刷新机制
2.1 核心思路
单 Token 方案顾名思义——只用一个 JWT 完成认证。但它也面临有效期设置的困境:设得太短,用户动不动就要重新登录;设得太长,Token 一旦泄露,攻击者就能长期冒用。
为了解决这个问题,单 Token 方案引入了一个“刷新窗口”(MaxRefresh)机制:
- 设置两个时间参数:
exp(Token 真正的过期时间,比如 15 分钟)和MaxRefresh(刷新窗口,比如 7 天)。 - Token 中携带一个特殊声明
orig_iat(原始签发时间),用于判断当前 Token 是否在允许刷新的时间窗口内。 - 当 Token 过期(
exp已过)但仍在MaxRefresh窗口内时,客户端可以调用/refresh接口,服务端验证orig_iat是否在窗口内,若是则生成一个新 Token(保留原始orig_iat,而不是重新设置签发时间),然后返回给客户端。
2.2 存在的问题
这种方案虽然功能上等价于双 Token,但在实践中暴露了几个明显问题:
① 客户端复杂度高:每次请求都要捕获 401 错误,判断是否可刷新,刷新后重试原请求——这套逻辑需要在 Web、iOS、Android 三个端各实现一遍。
② 用户体验差:用户填写一个长表单,填到 15 分钟后点击提交,结果因为 Token 过期先弹出一个短暂的“刷新中”的卡顿,体验不流畅。
③ 服务端无感知:服务端无法主动告诉客户端“你的 Token 快过期了,提前换一个”,只能被动等待客户端过来刷新。
2.3 优化方案:透明刷新机制
为了解决上述体验问题,业界提出了**透明刷新(Transparent Refresh)**机制——把刷新逻辑从客户端移到服务端中间件,实现对客户端完全透明的自动续期。
关键设计:
- 中间件判断:当 Token 过期但仍在 MaxRefresh 窗口内时,不返回 401,而是自动生成一个新 Token。
- 新 Token 通过响应头(如
X-New-Token)或 HttpOnly Cookie 返回给客户端。 - 客户端拦截器捕获响应头,更新本地 Token,然后下次请求自动使用新 Token——整个过程用户完全无感知。
透明刷新机制的弊端:
- 每次请求都可能产生新的 Token,服务端生成 JWT 的成本会增加(虽然签名计算开销很小)。
- 如果响应头丢失或客户端没有正确保存,会导致 Token 不同步,反而引入问题。
- 中间件必须能够静默更新 Token 而不影响原始请求的响应——这意味着新 Token 只能通过额外的响应字段传递,而不是修改请求本身。
即便如此,透明刷新仍是目前单 Token 方案中比较优雅的实现方式,适合对用户体验要求较高且希望客户端逻辑保持简单的场景。
三、双 Token 刷新机制
3.1 核心思路
双 Token 方案将认证凭证拆分为两个独立组件:
- Access Token(访问令牌):短期有效(通常 15–30 分钟),直接嵌入请求头用于访问 API,泄露风险窗口小。
- Refresh Token(刷新令牌):长期有效(通常 7–30 天,也有设 10–30 天的场景),仅用于刷新 Access Token,不参与常规 API 请求,存储于更安全的位置。
这种 “短期授权 + 长期授权分离” 的安全模型,既保证了业务接口的安全性,又避免了频繁重认证带来的用户体验损失。
3.2 三验证流程
完整的双 Token 验证流程通常包含三个验证环节:
3.3 关键工程实现点
3.3.1 存储策略
Refresh Token 必须服务端存储。与 Access Token 不同,Refresh Token 需要支持主动吊销和单次使用,不能完全无状态。通常使用 Redis 存储:
// 生成 refreshToken 并存入 redisconst redisKey = `REFRESH_TOKEN_PREFIX_${md5(refreshToken)}`;await redis.setex(redisKey, 7 * 24 * 3600, refreshToken);前端存储方案因平台而异:
- Web 端:推荐使用 Secure + HttpOnly Cookie。这样 JavaScript 无法访问,有效防御 XSS 攻击,再配合 CSRF Token 防御跨站请求伪造。
- 移动端:使用平台安全存储 API——iOS 用 Keychain,Android 用 Keystore。
- 最安全架构:BFF(Backend for Frontend)模式——所有认证和 Token 处理都在后端完成,JWT 永远不会暴露给浏览器存储或 JavaScript 访问。
3.3.2 令牌轮换机制
每次使用 Refresh Token 换取新 Access Token 时,同时生成新的 Refresh Token,并立即使旧的 Refresh Token 失效。
async function rotateRefreshToken(oldRefreshToken) { // 验证旧 Refresh Token const decoded = await verifyRefreshToken(oldRefreshToken); // 使旧 Token 立即失效 await revokeToken(oldRefreshToken); // 生成新 Token 对 const newAccessToken = generateAccessToken(decoded.userId); const newRefreshToken = generateRefreshToken(decoded.userId); return { accessToken: newAccessToken, refreshToken: newRefreshToken };}轮换机制的核心安全收益:即便攻击者截获了 Refresh Token,也只能使用一次,因为第二次请求时该令牌已经被轮换掉了。
3.3.3 并发刷新冲突处理
轮换机制在多个客户端并发刷新时会产生“竞态条件”——客户端 A 和客户端 B 同时发起刷新请求,服务端处理了 A 的请求后,旧 Refresh Token 立即失效,导致 B 的请求失败并返回 invalid_grant 错误。
解决方案:
- 前端互斥锁:通过
refreshLock标志确保同一时刻只有一个刷新请求在处理,其他请求等待锁释放后获取最新 Token。 - 服务端版本号:在 Refresh Token 中嵌入版本号字段,服务端验证版本号一致性,冲突时返回并发修改错误。
3.3.4 防盗检测
由于 Refresh Token 长期有效且权限大,需要增加额外的安全检测维度:
- 设备绑定:Refresh Token 与
deviceId强关联,检测到跨设备使用则主动失效。 - IP 漂移检测:同一 Refresh Token 在短时间内出现 IP 地址变化时,触发安全警告或强制重新认证。
- 版本号机制:每次刷新时递增
token_version,服务端存储最新版本号,旧版本 Refresh Token 直接拒绝。
四、单 Token 与双 Token 方案对比
| 维度 | 单 Token(透明刷新) | 双 Token(Access + Refresh) |
|---|---|---|
| 服务端存储需求 | 无需存储(仅需配置刷新窗口) | Refresh Token 需要存储(Redis/DB) |
| Token 数量 | 1 个 | 2 个,各司其职 |
| 安全性 | 较高——Token 短效 + 无存储 | 高——长短期分离 + 主动吊销 |
| 主动吊销支持 | 不支持(无服务端记录) | 支持(通过吊销 Refresh Token 实现) |
| 客户端复杂度 | 低(透明刷新的情况下客户端无需处理 401) | 中(需要正确管理两个 Token 并处理刷新请求的并发) |
| 适用场景 | 轻量级系统、单页应用(SPA)、对实现复杂度敏感的项目 | 企业级应用、移动端、需要主动控制会话的场景 |
| 成熟度 | 较新,参考实现较少 | 非常成熟,OAuth2 标准,业界广泛使用 |
五、JWT 的底层逻辑
理解了两种 Token 方案的架构后,有必要深入剖析其底层载体——JWT。JWT 的“无状态、自包含”特性赋予了它水平扩展的优势,但也带来了安全边界和约束。
5.1 JWT 的结构
JWT 的格式是固定的三段式结构,每段用点号隔开:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c三段分别是 Header、Payload、Signature,每段都是 Base64URL 编码的。Base64URL 是标准 Base64 的变体,把 + 换成 -,/ 换成 _,并去掉末尾的 =。这样 JWT 可以安全地放在 URL 参数或 HTTP 请求头里。
Header(头部)
{ "alg": "HS256", "typ": "JWT"}| 字段 | 含义 | 说明 |
|---|---|---|
alg | 签名算法 | 可选 HS256、RS256、ES256,绝对不能在生产环境用 none |
typ | 令牌类型 | 固定为 JWT,规范建议携带 |
kid | 密钥 ID | 可选,用于多密钥轮换场景下告知服务端该用哪个密钥验签 |
最关键的安全陷阱:如果服务端直接信任 alg 字段而不做强制校验,攻击者可以将 alg 改成 none 来完全绕过签名验证。生产环境必须使用白名单机制,只允许白名单内的算法。
Payload(载荷)
Payload 存放实际的身份信息,不是加密的,只是 Base64 编码。任何人都可以解码看到原始内容。标准声明(Claims)包括:
| 字段 | 全称 | 含义 |
|---|---|---|
iss | Issuer | 签发者 |
sub | Subject | 主题(通常是用户 ID) |
aud | Audience | 接收方 |
exp | Expiration Time | 过期时间(必须校验) |
iat | Issued At | 签发时间 |
nbf | Not Before | 生效时间 |
jti | JWT ID | 唯一标识(用于防止重放攻击) |
业务自定义声明可以任意添加,但绝对不要放敏感信息(如密码、身份证号)——因为它们只是 Base64 编码,不是加密。
Signature(签名)
签名是 JWT 安全的核心。以 HS256 为例:
Signature = HMACSHA256(base64UrlEncode(Header) + '.' + base64UrlEncode(Payload), secretKey)签名验证的核心在于 验证签名是否由合法密钥生成。常用算法分为两类:
- 对称算法(HS256):单服务场景,加密解密用同一个密钥,简单但密钥分发困难。
- 非对称算法(RS256、ES256、EdDSA):私钥签名、公钥验证,适合微服务架构和开放平台。
2025 年的安全趋势中,EdDSA(Ed25519)是推荐的首选算法,提供出色的性能和安全性;ES256(ECDSA with P-256)是优异的备选。HS256 仍可使用,但密钥至少要有 256 位的熵。
5.2 JWT 的生成与验证流程
生成流程
// 生成 JWT(Node.js + jsonwebtoken 示例)const jwt = require("jsonwebtoken");
const payload = { sub: "user123", name: "John Doe", role: "admin", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15分钟后过期 jti: uuidv4(),};
const token = jwt.sign(payload, SECRET_KEY, { algorithm: "HS256" });验证流程
验证签名只是第一步,完整的验证链条必须包含:
| 校验步骤 | 说明 |
|---|---|
| 签名校验 | 用密钥重新计算 Signature 并比对 |
exp 校验 | 检查是否已过期 |
nbf 校验 | 检查是否未到生效时间 |
aud 校验 | 检查接收方是否匹配 |
jti 校验 | 查黑名单防止重放攻击 |
try { const decoded = jwt.verify(token, SECRET_KEY, { algorithms: ["HS256"], audience: "your-api-audience", }); // 可选:额外校验 jti 是否在黑名单 if (await isTokenRevoked(decoded.jti)) { throw new Error("Token has been revoked"); }} catch (err) { // 过期、签名错误、黑名单等情况统一拒绝访问}特殊优化:验证 exp 已过期但 orig_iat(原始签发时间)还在 MaxRefresh 窗口内的场景,可以进入刷新流程。
5.3 JWT 的安全边界
④ 四大安全隐患与应对
| 隐患 | 攻击方式 | 应对措施 |
|---|---|---|
| 重放攻击 | 攻击者截获 JWT,重复发送直到 exp 过期 | 使用 jti + 服务端黑名单 / 极短 exp |
| 越权 | 攻击者修改 Payload 中的角色字段后重新签名 | 必须验证签名完整性,服务端不要信任 Payload 中的权限,应从数据库或权限服务获取 |
| 吊销/踢人 | 用户退出或被踢出,但 JWT 仍在有效期内 | 维护短 exp(15–30 分钟),或维护黑名单(每请求查一次) |
| 算法混淆 | 攻击者将 alg 改为 none 或 HS256 与 RS256 混用攻击 | 强制使用白名单算法,服务端严格校验 |
⑤ 存储安全:多端策略表格
| 平台 | 推荐存储位置 | 优点 | 注意事项 |
|---|---|---|---|
| Web 端(首选) | Secure + HttpOnly Cookie | 防御 XSS,JS 无法访问 | 需配合 CSRF Token 防御 |
| Web 端(备选) | BFF 模式 | Token 永远不暴露给浏览器 | 增加一层后端代理,架构复杂 |
| iOS | Keychain | 系统级安全存储,提供静态加密 | 需额外处理 Keychain 的读取失败重试 |
| Android | Keystore | 隔离敏感信息,提供静态加密 | API 版本兼容性需注意 |
| 不推荐 | localStorage / sessionStorage | —— | 任何 XSS 漏洞都能直接窃取 Token |
⑥ 传输安全:HTTPS 是底线
在大多数实现中,JWT 作为 Bearer Token 使用——谁持有 Token 谁就能访问受保护资源。因此传输安全没有商量余地。没有 HTTPS,攻击者可以通过中间人攻击截获 Token 并冒充用户。
六、总结
| 维度 | 单 Token(透明刷新) | 双 Token(Access + Refresh) |
|---|---|---|
| 服务端存储 | 无需存储 | Refresh Token 需存储 |
| 安全性 | 较高 | 高(支持主动吊销) |
| 客户端复杂度 | 低(透明刷新无需处理 401) | 中(需管理双 Token 及并发) |
| 适用场景 | 轻量级系统、SPA、快速原型 | 企业级、移动端、需主动控制会话 |
| JWT 结构 | 三段式 Base64URL 编码 Header + Payload + Signature | 同左,但需实现刷新接口和轮换逻辑 |
| 核心安全措施 | 短 exp + 签名校验 + 透明刷新 | 长短期分离 + Token 轮换 + 并发控制 + 防盗检测 |
无论选择哪种方案,都必须严格遵循以下基本原则:强制 HTTPS 传输、JWT Payload 永不存储敏感信息、签名算法使用白名单防止算法混淆、exp 时间设置合理(建议 15–30 分钟 Access Token,7–30 天 Refresh Token)。从整体安全设计角度看,双 Token 方案是行业主流标准,尤其适合需要多设备登录、会话主动管理和严格权限控制的企业级场景。透明刷新机制虽能简化单 Token 方案的客户端实现,但其更新 Token 的可靠性和并发一致性仍需仔细权衡。建议根据项目规模、团队能力与安全需求选择合适的方案。
本文涵盖的内容较多,如果对某个具体环节的实现(比如并发刷新冲突的完整代码、BFF 模式的具体架构、JWT 黑名单的工程实现等)需要更详细的展开,可以告诉我,我可以针对这些方向进一步补充。
Some information may be outdated