### 令牌生命周期管理:生老病死,一个都不能少

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天),专门用来“后台换证”。

刷新流程的技术要点:

  1. 自动检测并触发:客户端拦截401响应,不弹框、不打扰用户,自动携带refresh_换取新的access_。
  2. 轮换策略(Rotation):每次刷新后,旧的refresh_立即失效,同时下发一个新的refresh_。这是目前最安全的实践。
  3. 复用检测(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,刷新时做轮换,撤销时用黑名单或版本号,过期时间务必合理。这六步走完,你的令牌体系才算真正成型。