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 的刷新:别让用户感知到

刷新逻辑要做得「无感」。用户正在操作,突然弹个「登录已过期」——这体验太差了。

标准流程是这样的:

  1. 前端发请求,后端发现 Access Token 过期,返回 401。
  2. 前端拦截器捕获 401,自动用 Refresh Token 去换新 Token。
  3. 换成功后,用新 Token 重放刚才失败的请求。
  4. 用户完全不知道背后发生了刷新。

关键点:刷新接口本身也要防重放。我习惯在 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 从生成到吊销的完整生命周期。我把它画成了流程图,方便你对照理解。

Token 完整生命周期流程图 用户登录 生成 AT + RT AT → 前端内存 RT → httpOnly Cookie 请求资源(带 AT) 校验 AT 是否有效 AT 有效 → 放行 AT 过期 → 走刷新 吊销 RT / 版本号递增 AT 短命 + RT 安全存储 = 核心防线

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 天,这是我试过很多组合后觉得最舒服的配置。当然,具体还得看你的业务场景——金融类系统可以更短,社交类可以适当放宽。