令牌生命周期管理:生成、颁发、存储、刷新、撤销与销毁
令牌不是永生的。它从诞生到消亡,每个环节都可能出问题。我见过太多团队只关注令牌怎么生成,却忽略了后面那几步——结果线上出了安全事故,追查下来才发现是过期令牌没清理。
说白了,令牌生命周期管理就是一套从生到死的规矩。你想想看,一个令牌如果没人管它什么时候失效、怎么撤销,那跟把钥匙挂在门口有什么区别?
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);
// 攻击者可以预测!
2.2 令牌颁发:绑定与声明
令牌生成出来,得发给正确的用户。这一步的关键是「绑定」——令牌必须和用户身份、设备信息、会话上下文牢牢绑在一起。
我建议在颁发时做三件事:
- 记录令牌指纹——在服务端存储令牌的哈希值(SHA-256),而不是明文。这样即使数据库泄露,攻击者也拿不到有效令牌。
- 嵌入设备上下文——在令牌的 claims(声明)里带上设备 ID、IP 段、用户代理等信息。后续每次请求都校验这些信息是否匹配。
- 设置合理的过期时间——访问令牌(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(可信执行环境)存储私钥,令牌本身尽量短命。
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 };
}
2.5 令牌撤销:即时性与传播
令牌撤销的难点在于「即时性」。你想想看,用户修改密码或登出后,旧的令牌理论上应该立刻失效。但分布式系统里,令牌校验服务可能有多个实例,撤销信息怎么同步?
我常用的方案有三种:
- 黑名单(Blacklist):用 Redis 维护一个撤销令牌的集合,每次请求都查一下。适合撤销量不大的场景。
- 令牌版本号:在用户表里维护一个
token_version字段,每次撤销时递增。令牌中嵌入这个版本号,校验时比对。适合高并发场景,但粒度较粗(撤销所有令牌)。 - 短有效期 + 主动刷新:把 Access Token 的有效期压到 5 分钟,撤销时只需要等它自然过期。适合对实时性要求不高的场景。
| 方案 | 实时性 | 性能开销 | 适用场景 |
|---|---|---|---|
| 黑名单(Redis) | 高 | 中等(每次请求查 Redis) | 用户量百万级以下 |
| 令牌版本号 | 高 | 低(只需查一次用户信息) | 高并发、批量撤销 |
| 短有效期 | 低(需等待过期) | 极低 | 对实时性要求不高的系统 |
2.6 令牌销毁:不留痕迹
令牌销毁是生命周期的最后一环,也是最容易被忽略的一环。很多人觉得「过期了就不管了」,但过期令牌如果还留在数据库里,就是潜在的风险点。
我建议的销毁策略:
- 软删除:将令牌状态标记为
revoked或expired,保留一段时间用于审计。 - 硬删除:超过审计保留期(我一般设 90 天)后,物理删除令牌记录。
- 客户端清理:登出时,客户端必须主动清除内存和存储中的令牌。别指望用户手动清理。
2.7 全生命周期流程图
下面这张图把整个流程串起来了。你可以把它贴在团队的白板上,每次设计令牌系统时对照着检查。
2.8 避坑指南:我踩过的那些雷
最后,分享几个我亲身经历过的教训,希望能帮你少走弯路:
- 别把 Refresh Token 当 Access Token 用——我见过有人把 Refresh Token 直接放在请求头里当认证凭证,结果 Refresh Token 有效期 30 天,泄露后攻击者能浪一个月。
- 撤销操作一定要加审计日志——有一次线上出问题,我们怀疑令牌被批量盗用,结果发现撤销日志没记录操作人,查了半天查不到是谁干的。从那以后,每次撤销我都要求记录:操作人、时间、原因、影响的令牌数量。
- 测试环境别用生产环境的令牌生成逻辑——有个团队把测试环境的随机种子固定了,结果所有测试令牌都一样,上线前忘了改回来……嗯,你可以想象那天的线上事故有多热闹。
- 令牌过期时间别设得太整齐——如果所有用户的令牌都在同一时刻过期,服务端会瞬间收到海量刷新请求,直接把数据库打崩。我习惯在过期时间上加一个随机偏移(±5 分钟),分散压力。