3. 令牌生命周期管理:生成、分发、存储、刷新、撤销、过期
令牌这东西,说白了就是一把临时的钥匙。钥匙怎么打、怎么给、放哪儿、用旧了怎么换、丢了怎么锁……这些环节但凡有一个出问题,安全防线就形同虚设。
我见过不少团队,最开始只盯着“令牌怎么签”这一个点,觉得签出来就完事了。结果上了生产环境,令牌泄露了没法撤销、过期时间设得太长、刷新逻辑有漏洞……全成了定时炸弹。嗯,今天我们就把这把钥匙的一生给掰扯清楚。
令牌生命周期,我认为最核心的就这六个阶段:
- 生成:保证令牌的不可伪造性和唯一性
- 分发:保证令牌在传输过程中的机密性
- 存储:保证令牌在客户端/服务端的静态安全
- 刷新:平衡安全性与用户体验的后勤保障
- 撤销:发生泄露后的应急刹车
- 过期:自动失效的最后一道防线
接下来我们逐个拆解。先看看这张图,能帮你快速理解它们之间的循环关系:
3.1 生成:别用「随机」糊弄自己
令牌生成是安全的第一道关。很多人喜欢用UUID或者简单的随机字符串,觉得“随机”两个字就够了。其实不然。
你想想看,如果攻击者能猜出你的令牌生成算法,那他根本不需要盗取令牌——直接伪造就行了。我遇到过最夸张的情况,有个项目直接用时间戳+用户ID组合,连HMAC都没加。结果呢?上线第一天就被爬虫把数据拖了个干净。
我的建议是:
- 用高熵随机源:别用Math.random(),要用加密学安全的伪随机数生成器(CSPRNG)。比如Python的
secrets模块、Java的SecureRandom、Node.js的crypto.randomBytes。 - 结构化签名 vs 无状态令牌:如果选择JWT,一定要用强签名算法(RS256或ES256,别用HS256这种对称密钥)。如果选择opaque令牌(不透明随机字符串),那就确保足够长度,至少32字节。
- 额外加上一点“盐”:把令牌与用户上下文绑定,比如嵌入设备指纹、IP段或Session ID。这样就算令牌泄露,也无法异地使用。
// 不推荐
const = uuidv4(); // 猜得到吗?很抱歉,很多时候能猜到
// 我推荐的做法(Node.js)
const crypto = require('crypto');
function generateSecureToken(payload) {
const raw = crypto.randomBytes(32).toString('hex');
// 结合用户上下文生成签名
return raw + '.' + crypto.createHmac('sha256', secret).update(raw + payload).digest('hex');
}
3.2 分发: HTTPS是底线,别玩花活
令牌分发只做一件事:把令牌安全地交给客户端。这个阶段最容易出问题的,就是通道安全。
核心原则:
- 强制HTTPS:这是底线中的底线。明文传输令牌等同于把钥匙贴在门上。我建议你在网关层就做HTTP到HTTPS的重定向,别把命运交给客户端。
- 避免URL参数传递令牌:很多人在回调URL里带access_,这简直是自杀式设计。URL会留在服务器日志、浏览器历史、Referer头里。一定要放在请求体或Header里。
- 合理设置响应头:返回令牌时,用
Cache-Control: no-store禁止浏览器缓存,用Pragma: no-cache做兼容。对于刷新令牌,推荐使用HttpOnly、Secure、SameSite=Strict的Cookie来传输。
我踩过的坑:
曾经有个项目,在登录接口里把access_和refresh_一起用JSON返回。前端直接存在localStorage里。结果呢?一个XSS脚本就让我加班整晚。从那以后,我一直坚持:访问令牌可以用内存变量存,但刷新令牌必须用HttpOnly Cookie。
3.3 存储:别把鸡蛋放在一个篮子里
令牌在客户端的存储方式,直接决定了泄露后的攻击面。来,我列个表你看一眼:
| 存储位置 | 安全等级 | 漏洞类型 | 我的建议 |
|---|---|---|---|
| localStorage | 低 | XSS直接窃取 | 尽量别用,尤其不要存储refresh_ |
| sessionStorage | 中 | 同站XSS + 跨Tab访问 | 短时令牌(如access_)可考虑 |
| Cookie (HttpOnly + Secure + SameSite) | 高 | 需要CSRF防御 | 刷新令牌的首选 |
| 内存变量(闭包/JS变量) | 最高 | 刷新页面丢失 | 访问令牌的黄金方案 |
我个人习惯是:访问令牌留在前端内存,刷新令牌走HttpOnly Cookie。这样就算遇到XSS,攻击者也拿不到刷新令牌,无法持续刷新。服务端呢?记得把刷新令牌做哈希后存入数据库,别存明文。
3.4 刷新:无声无息的后勤兵
刷新机制就是令牌的换发过程。为什么要有这一步?因为访问令牌周期短(一般15分钟),你不能每次都让用户重新登录。刷新令牌周期长(7-30天),专门用来“后台换证”。
刷新流程的技术要点:
- 自动检测并触发:客户端拦截401响应,不弹框、不打扰用户,自动携带refresh_换取新的access_。
- 轮换策略(Rotation):每次刷新后,旧的refresh_立即失效,同时下发一个新的refresh_。这是目前最安全的实践。
- 复用检测(Reuse Detection):如果发现同一个refresh_被使用多次,立即撤销该用户的所有令牌。这基本意味着令牌已经被盗了,必须强制重新登录。
重要提醒:
别把access_的过期时间设得太长,比如24小时。那样一旦泄露,攻击者有整整一天的时间滥用。我认为合理的做法是:access_ 15分钟,refresh_ 7天,并且每次刷新都要做轮换。
3.5 撤销:该断则断,别拖
令牌泄露、用户登出、权限变更——这些时候你需要立刻让令牌失效。但问题是,如果你用的是JWT这种无状态令牌,它本身就没办法主动失效。嗯,这就是问题所在。
我常用的三种方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 黑名单(Blacklist) | 将需要撤销的令牌的jti(JWT ID)存入Redis,每次请求时检查 | 服务端能控制的业务令牌 |
| 版本号机制 | 在用户表中维护一个_version字段,JWT里嵌入该字段。版本变化后,旧令牌自动失效 | 用户密码修改、强制登出 |
| 短TTL + 强制刷新 | access_设5分钟有效期,撤销时只删refresh_,让用户无法刷新 | 对用户体验要求高的场景 |
一个小技巧:
黑名单别用“活不过”的遍历检查。用Redis存储待撤销的令牌jti,配合过期时间自动清理。这样查询效率是O(1),不会拖垮你的API。
我曾经在做一个支付网关时,发现某个用户的refresh_被异常使用。按照复用检测规则,我立即把所有关联令牌加入黑名单,同时封禁了该用户的所有活跃会话。从检测到撤销,整个过程不到200ms。嗯,该果断的时候绝不能犹豫。
3.6 过期:最后的防线
过期的核心价值是:就算所有防御都失效了,时间会自动把门锁死。我不相信任何永不过期的令牌,不管它生成得多安全。
我的过期策略三原则:
- 分层设置:access_ 15-30分钟,refresh_ 7-30天,长期刷新令牌不超过90天。
- 允许“宽限期”:access_过期后,给客户端5秒的缓冲时间,同时返回
401 + _expired错误码,明确告诉客户端需要刷新。 - 提前告知:在JWT的payload里嵌入
exp和iat字段。客户端可以根据exp提前几秒发起刷新,避免请求被中断。
// JWT payload 示例
{
"sub": "user_12345",
"iat": 1697025600,
"exp": 1697026500, // 15分钟后过期
"jti": "a1b2c3d4e5f6g7h8",
"_version": 3
}
总结一下令牌生命周期的核心思想:每个阶段都要做到“可控制、可追溯、可强制失效”。生成时选对随机源,分发时走HTTPS,存储时用HttpOnly Cookie,刷新时做轮换,撤销时用黑名单或版本号,过期时间务必合理。这六步走完,你的令牌体系才算真正成型。