令牌生命周期管理:生成、颁发、存储、刷新、撤销与销毁

令牌不是永生的。它从诞生到消亡,每个环节都可能出问题。我见过太多团队只关注令牌怎么生成,却忽略了后面那几步——结果线上出了安全事故,追查下来才发现是过期令牌没清理。

说白了,令牌生命周期管理就是一套从生到死的规矩。你想想看,一个令牌如果没人管它什么时候失效、怎么撤销,那跟把钥匙挂在门口有什么区别?

核心观点:令牌生命周期管理的本质是「最小化暴露窗口」。每个环节的设计目标,都是让令牌在必要的时间、必要的范围内生效,其余时间一律不可用。

2.1 令牌生成:熵源与算法选择

生成令牌这件事,看着简单——不就是随机字符串吗?但坑很深。我曾经接手过一个项目,他们用 Math.random() 生成令牌,结果被攻击者通过时间种子预测出了所有令牌。嗯,那场面挺尴尬的。

正确的做法是:

  • 使用密码学安全的随机数生成器(CSPRNG)——比如 Node.js 的 crypto.randomBytes(),Python 的 secrets.token_hex(),Java 的 SecureRandom。
  • 令牌长度至少 128 位(16 字节),我个人习惯用 256 位,多花不了多少性能,但安全裕度大很多。
  • 避免可预测的编码方式——别用 UUID 版本 1(基于时间戳和 MAC 地址),用 UUID 版本 4(随机)或直接生成随机字节后 Base64 编码。
// 推荐:Node.js 生成安全令牌
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('hex');
// 输出示例:a3f8c9d1e2b4...(64 个十六进制字符)

// 不推荐:Math.random 生成令牌
const badToken = Math.random().toString(36).substring(2);
// 攻击者可以预测!
我的习惯:生成令牌时,我会在令牌内容中嵌入一个 1 字节的版本号。这样以后升级算法时,可以同时兼容新旧令牌,不用强制所有用户重新登录。

2.2 令牌颁发:绑定与声明

令牌生成出来,得发给正确的用户。这一步的关键是「绑定」——令牌必须和用户身份、设备信息、会话上下文牢牢绑在一起。

我建议在颁发时做三件事:

  1. 记录令牌指纹——在服务端存储令牌的哈希值(SHA-256),而不是明文。这样即使数据库泄露,攻击者也拿不到有效令牌。
  2. 嵌入设备上下文——在令牌的 claims(声明)里带上设备 ID、IP 段、用户代理等信息。后续每次请求都校验这些信息是否匹配。
  3. 设置合理的过期时间——访问令牌(Access Token)我一般设 15-30 分钟,刷新令牌(Refresh Token)设 7-30 天。别学某些平台设一年,那是给自己埋雷。
令牌类型 典型有效期 存储位置 安全要求
Access Token 15 分钟 - 1 小时 内存 / 短期缓存 必须签名,不存储明文
Refresh Token 7 天 - 30 天 服务端数据库(哈希后) 一次性使用,轮换机制
ID Token 与 Access Token 相同 客户端解析后丢弃 仅用于身份信息传递

2.3 令牌存储:客户端与服务端的分工

令牌存哪儿?这是个老生常谈但总有人犯错的问题。

服务端存储:只存哈希,不存明文。我习惯用专门的令牌表,字段包括:令牌哈希、用户 ID、设备指纹、颁发时间、过期时间、状态(有效/撤销)。

客户端存储:这里争议最大。我的建议是:

  • Web 端:Access Token 放内存变量(或 sessionStorage),Refresh Token 放 httpOnly + Secure + SameSite=Strict 的 Cookie。别放 localStorage——XSS 攻击一锅端。
  • 移动端:用系统提供的安全存储(iOS 的 Keychain,Android 的 EncryptedSharedPreferences)。
  • IoT 设备:用硬件安全模块(HSM)或 TEE(可信执行环境)存储私钥,令牌本身尽量短命。
我曾经踩过的坑:有个项目把 Refresh Token 明文存在 localStorage 里,结果一个第三方脚本的 XSS 漏洞把所有用户的长期令牌都偷走了。那次之后,我定了个死规矩——任何持久化存储的令牌,必须走 httpOnly Cookie。

2.4 令牌刷新:轮换与重用检测

刷新令牌(Refresh Token Rotation)是 OAuth 2.0 里最容易被忽视的环节。说白了,每次使用 Refresh Token 换取新的 Access Token 时,旧的 Refresh Token 必须立即失效,并颁发一个新的 Refresh Token。

为什么?因为如果 Refresh Token 可以重复使用,攻击者只要偷到一次,就能永远刷新下去。我见过一个案例:攻击者通过中间人攻击拿到一个 Refresh Token,然后每个月刷新一次,整整用了半年才被发现。

实现轮换的伪代码:

function refreshTokens(oldRefreshTokenHash) {
  // 1. 验证旧 Refresh Token 是否存在且有效
  const tokenRecord = db.findTokenByHash(oldRefreshTokenHash);
  if (!tokenRecord || tokenRecord.status !== 'active') {
    throw new Error('Invalid refresh token');
  }

  // 2. 生成新的令牌对
  const newAccessToken = generateAccessToken(tokenRecord.userId);
  const newRefreshToken = generateRefreshToken();
  const newRefreshTokenHash = hashToken(newRefreshToken);

  // 3. 在同一个事务中:撤销旧令牌,插入新令牌
  db.transaction(() => {
    db.revokeToken(oldRefreshTokenHash);
    db.insertToken(newRefreshTokenHash, tokenRecord.userId);
  });

  // 4. 返回新令牌
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
重用检测:如果服务端收到一个已经被撤销的 Refresh Token,说明可能有人盗用了它。这时候我建议立即吊销该用户的所有令牌,并要求重新登录。这叫「检测到令牌重用,触发全局失效」。

2.5 令牌撤销:即时性与传播

令牌撤销的难点在于「即时性」。你想想看,用户修改密码或登出后,旧的令牌理论上应该立刻失效。但分布式系统里,令牌校验服务可能有多个实例,撤销信息怎么同步?

我常用的方案有三种:

  • 黑名单(Blacklist):用 Redis 维护一个撤销令牌的集合,每次请求都查一下。适合撤销量不大的场景。
  • 令牌版本号:在用户表里维护一个 token_version 字段,每次撤销时递增。令牌中嵌入这个版本号,校验时比对。适合高并发场景,但粒度较粗(撤销所有令牌)。
  • 短有效期 + 主动刷新:把 Access Token 的有效期压到 5 分钟,撤销时只需要等它自然过期。适合对实时性要求不高的场景。
方案 实时性 性能开销 适用场景
黑名单(Redis) 高 中等(每次请求查 Redis) 用户量百万级以下
令牌版本号 高 低(只需查一次用户信息) 高并发、批量撤销
短有效期 低(需等待过期) 极低 对实时性要求不高的系统

2.6 令牌销毁:不留痕迹

令牌销毁是生命周期的最后一环,也是最容易被忽略的一环。很多人觉得「过期了就不管了」,但过期令牌如果还留在数据库里,就是潜在的风险点。

我建议的销毁策略:

  1. 软删除:将令牌状态标记为 revoked 或 expired,保留一段时间用于审计。
  2. 硬删除:超过审计保留期(我一般设 90 天)后,物理删除令牌记录。
  3. 客户端清理:登出时,客户端必须主动清除内存和存储中的令牌。别指望用户手动清理。
注意:销毁令牌时,一定要同步清理相关的会话数据、缓存条目和审计日志中的敏感信息。我曾经见过一个系统,令牌删了但 Redis 缓存里还留着用户会话,结果攻击者通过缓存穿透又拿到了有效会话。

2.7 全生命周期流程图

下面这张图把整个流程串起来了。你可以把它贴在团队的白板上,每次设计令牌系统时对照着检查。

令牌生命周期管理流程图 令牌生命周期管理流程 ① 生成 CSPRNG + 256位 ② 颁发 绑定设备 + 声明 ③ 存储 服务端哈希 + 安全存储 ④ 刷新 轮换 + 重用检测 ⑤ 撤销 黑名单 / 版本号 ⑥ 销毁 软删除 → 硬删除 重用检测触发 用户登出 / 修改密码 关键原则: • 生成:必须用密码学安全随机数,长度 ≥ 128 位 • 颁发:绑定设备上下文,设置合理有效期 • 存储:服务端只存哈希,客户端用 httpOnly Cookie 或安全存储 • 刷新:必须轮换,检测重用立即全局失效 • 撤销:选择适合场景的方案(黑名单 / 版本号 / 短有效期) • 销毁:软删除保留审计,硬删除彻底清除

2.8 避坑指南:我踩过的那些雷

最后,分享几个我亲身经历过的教训,希望能帮你少走弯路:

  • 别把 Refresh Token 当 Access Token 用——我见过有人把 Refresh Token 直接放在请求头里当认证凭证,结果 Refresh Token 有效期 30 天,泄露后攻击者能浪一个月。
  • 撤销操作一定要加审计日志——有一次线上出问题,我们怀疑令牌被批量盗用,结果发现撤销日志没记录操作人,查了半天查不到是谁干的。从那以后,每次撤销我都要求记录:操作人、时间、原因、影响的令牌数量。
  • 测试环境别用生产环境的令牌生成逻辑——有个团队把测试环境的随机种子固定了,结果所有测试令牌都一样,上线前忘了改回来……嗯,你可以想象那天的线上事故有多热闹。
  • 令牌过期时间别设得太整齐——如果所有用户的令牌都在同一时刻过期,服务端会瞬间收到海量刷新请求,直接把数据库打崩。我习惯在过期时间上加一个随机偏移(±5 分钟),分散压力。
一句话总结:令牌生命周期管理的核心就六个字——生成、颁发、存储、刷新、撤销、销毁。每个环节都做到位,令牌安全就稳了八成。剩下的两成,靠监控和应急响应。