4. Token 的设计与安全:Access Token、Refresh Token 的生成、存储、刷新与吊销机制
Token 这东西,说白了就是一把临时钥匙。你拿着它,系统就认你是谁、能干什么。但钥匙怎么造、怎么存、怎么换、怎么废,这里面的门道可不少。我见过太多项目在 Token 上翻车——要么过期时间设得太长,泄露了也没法收场;要么刷新逻辑写得稀烂,用户动不动就被踢下线。
今天咱们就把这套机制拆开揉碎了讲。从生成到吊销,每个环节我都会结合自己踩过的坑来说。
4.1 Access Token 与 Refresh Token 的分工
先搞清楚这两个东西各自干什么活。
| Token 类型 | 生命周期 | 用途 | 存储位置 |
|---|---|---|---|
| Access Token | 短(15分钟~2小时) | 携带在请求头中,证明身份与权限 | 前端内存 / 短期缓存 |
| Refresh Token | 长(7天~30天) | 仅用于换取新的 Access Token | 后端数据库 / 安全 Cookie |
为什么非要拆成两个?你想想看,如果只有一个长期有效的 Token,一旦泄露,攻击者能拿它干一个月坏事。而 Access Token 短命,就算被偷了,也就十几分钟的有效期。Refresh Token 虽然活得久,但它只干一件事——换新 Token,而且必须走安全通道。
核心原则:Access Token 负责「快进快出」,Refresh Token 负责「安全续命」。两者职责分离,互不越界。
4.2 Token 的生成:别自己拼字符串
生成 Token 最忌讳的就是自己拼 JSON 然后 Base64 编码。我见过有人直接在 Token 里塞明文密码,还觉得没人能解码——拜托,Base64 又不是加密。
正确的做法是用标准库。JWT(JSON Web Token)是目前最主流的方案,但要注意签名算法。
// 生成 Access Token(Node.js + jsonwebtoken 示例)
const jwt = require('jsonwebtoken');
const accessToken = jwt.sign(
{
sub: userId, // 用户 ID
role: 'admin', // 角色
scope: 'read:write', // 权限范围
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 900 // 15分钟过期
},
process.env.ACCESS_TOKEN_SECRET,
{ algorithm: 'RS256' } // 推荐非对称算法
);
我的习惯:Access Token 用 RS256(非对称),Refresh Token 用 HS256(对称)。这样即使 Access Token 的公钥泄露,也无法伪造 Refresh Token。
生成 Refresh Token 时,我建议在 payload 里加一个 token_family 字段。这个字段用来标记同一批下发的 Token 家族,方便后续做批量吊销。
const refreshToken = jwt.sign(
{
sub: userId,
token_family: uuidv4(), // 同一批 Token 共享一个家族 ID
type: 'refresh',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 604800 // 7天
},
process.env.REFRESH_TOKEN_SECRET,
{ algorithm: 'HS256' }
);
4.3 Token 的存储:前端别碰 localStorage
存储位置选错了,前面所有努力都白费。
- Access Token:放在内存变量里。SPA 应用可以存在闭包或状态管理库中。页面刷新就没了,正好重新获取。
- Refresh Token:放在 httpOnly、Secure、SameSite=Strict 的 Cookie 里。这样 JS 拿不到,XSS 攻击也偷不走。
我曾经踩过的坑:早期一个项目把 Refresh Token 也放 localStorage,结果被 XSS 脚本一把全捞走。攻击者拿着 Refresh Token 不断换新 Access Token,直到我们手动清库才止住。从那以后,我规定所有敏感 Token 必须走 httpOnly Cookie。
后端存储 Refresh Token 时,别只存一个字符串。我习惯建一张表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | UUID | 主键 |
| user_id | VARCHAR | 用户 ID |
| token_hash | VARCHAR | Token 的 SHA-256 哈希 |
| token_family | UUID | 家族 ID,用于批量吊销 |
| expires_at | DATETIME | 过期时间 |
| revoked_at | DATETIME | 吊销时间(NULL 表示未吊销) |
存哈希而不是明文,是为了防止数据库泄露后攻击者直接拿到有效 Token。
4.4 Token 的刷新:别让用户感知到
刷新逻辑要做得「无感」。用户正在操作,突然弹个「登录已过期」——这体验太差了。
标准流程是这样的:
- 前端发请求,后端发现 Access Token 过期,返回 401。
- 前端拦截器捕获 401,自动用 Refresh Token 去换新 Token。
- 换成功后,用新 Token 重放刚才失败的请求。
- 用户完全不知道背后发生了刷新。
关键点:刷新接口本身也要防重放。我习惯在 Refresh Token 的 payload 里加一个 jti(JWT ID),后端记录已使用的 jti,防止同一 Token 被多次用来刷新。
还有一个容易被忽略的问题:并发请求。如果同时有 5 个请求都发现 Token 过期,它们会同时去刷新,导致 Refresh Token 被重复使用。解决办法是加一个「刷新锁」:
// 伪代码:刷新锁机制
let refreshPromise = null;
async function refreshToken() {
if (refreshPromise) {
return refreshPromise; // 已经有刷新在进行,直接返回
}
refreshPromise = fetch('/auth/refresh', { method: 'POST' })
.then(res => res.json())
.finally(() => { refreshPromise = null; });
return refreshPromise;
}
4.5 Token 的吊销:别只靠过期
用户改密码了、账号被盗了、员工离职了——这些场景都需要立即吊销 Token,不能等它自然过期。
吊销策略分两种:
- 黑名单模式:维护一个吊销列表,每次请求都查一下。优点是实时生效,缺点是每次都要查数据库,有性能开销。
- 版本号模式:在用户表里加一个
token_version字段,每次吊销就递增版本号。Token 里带上当前版本号,校验时比对。优点是零数据库查询,缺点是无法精确吊销单个 Token。
我个人更倾向混合策略:
| 场景 | 策略 | 说明 |
|---|---|---|
| 用户改密码 | 版本号模式 | 递增 token_version,所有旧 Token 立即失效 |
| 单个设备登出 | 黑名单模式 | 只吊销该设备的 Refresh Token |
| 账号被盗 | 版本号 + 黑名单 | 先递增版本号,再手动清理异常设备 |
注意:黑名单不能无限膨胀。我一般设置黑名单的 TTL 等于 Access Token 的有效期。比如 Access Token 15 分钟过期,那黑名单里的记录也只保留 15 分钟。过了这个时间,Token 本身已经失效,黑名单就没意义了。
4.6 整体流程:一张图说清楚
下面这张 SVG 图展示了 Token 从生成到吊销的完整生命周期。我把它画成了流程图,方便你对照理解。
4.7 避坑清单:我这些年总结的教训
- 别把 Refresh Token 当普通 Token 用:它只能走刷新接口,不能用来访问业务资源。我见过有人图省事,让 Refresh Token 也能当 Access Token 用——结果泄露后损失惨重。
- 刷新接口要做频率限制:同一个 Refresh Token 每分钟最多换一次。防止暴力破解。
- 吊销后要通知客户端:服务端吊销了 Token,客户端可能还傻傻地拿着旧 Token 在请求。我习惯在响应里加一个
X-Token-Revoked头,前端看到后立即清除本地状态并跳转登录页。 - 日志里别打 Token:哪怕只打前几位也不行。日志可能被第三方工具采集,Token 一旦泄露就是安全事件。
最后说一句:Token 设计没有银弹。你需要在安全性和用户体验之间找平衡。Access Token 设 15 分钟、Refresh Token 设 7 天,这是我试过很多组合后觉得最舒服的配置。当然,具体还得看你的业务场景——金融类系统可以更短,社交类可以适当放宽。