Node 后端实战 · JWT 双密钥轮转与 token 版本号:多租户 SaaS 如何不停机换密钥、一键踢全设备
各位看官,做多租户 SaaS 后端,认证是地基里的地基。地基没打牢,上面盖再多业务功能都是危房。我这个后端跑在 Cloudflare Workers 上,认证这一块前后改了好几版,踩的坑不算少。今天不铺开讲整个鉴权体系,就单挑两个最容易被忽视、但一旦出事就是大事故的点:JWT 密钥怎么轮换才不停机 ,以及怎么让一个用户的全部旧 token 瞬间失效(改密码、退出所有设备)。
这两个需求听起来简单,真落到无状态 JWT + 边缘 KV 的架构上,有不少反直觉的地方。下面把我实际用的方案、代码、以及为什么这么设计,一次讲透。

先说技术选型:jose + HS256
Workers 上签发校验 JWT,我选的是 jose 这个库------它基于 Web Crypto,在 Workers 运行时里是一等公民,不用额外 polyfill。算法用 HS256 对称签名,而不是很多人下意识选的 RS256。
原因很实在:RS256 要管公私钥对,公钥短但私钥长,Workers 上做非对称签名开销也大;而 HS256 一个字符串密钥搞定,密钥短、验签快、好轮换。多租户 SaaS 里签发量不小,对称签名更划算。
我的 token 里塞了这些业务声明(claims):
| 字段 | 类型 | 含义 |
|---|---|---|
sub |
string | 用户 ID(subject) |
tid |
string | null | 租户 ID(平台超管为 null) |
role |
string | 角色(租户管理员/员工/平台超管等) |
type |
"access" | "refresh" | token 类型,区分访问令牌与刷新令牌 |
jti |
string | 唯一标识,用于单设备吊销黑名单 |
tv |
number | token 版本号,用于全设备登出 |
mrp |
number | 强制改密标志(1=必须改密后才放行) |
并且我刻意用了双 token 结构:
| 维度 | access token | refresh token |
|---|---|---|
| 有效期 | 1 小时(ACCESS_TTL=3600) |
7 天(REFRESH_TTL=604800) |
| 用途 | 调业务接口 | 匿名端点换发新令牌 |
| 中间件 | 受保护路由只允许它进入 | 仅 /refresh 接受,别处一律拒绝 |
access 短命降低泄露风险,refresh 长命减少反复登录;refresh 是匿名端点,只能用来换发,绝不能当业务令牌用------这点后面代码会强制校验。
痛点一:密钥怎么轮换才不停机
JWT 一旦签发,服务端没有"存着"它,校验全靠那把密钥。于是麻烦来了:密钥泄露要换、合规要定期轮转,可线上几十万活跃 token 都是旧密钥签的,你直接换密钥,所有用户瞬间掉线。

我的解法是双密钥候选列表。验签时不只试一把密钥,而是依次试「当前密钥 + 上一轮密钥」:
ts
// lib/jwt.ts ------ 验签遍历多密钥,支持平滑轮换
export const verifyToken = async (token: string, secrets: string[]): Promise<JwtPayload> => {
let lastErr: unknown;
for (const secret of secrets) {
if (!secret) continue;
try {
const { payload } = await jwtVerify(token, encodeKey(secret), { algorithms: ["HS256"] });
return { /* ...还原 claims... */ };
} catch (e) {
lastErr = e; // 这把密钥验不过,试下一把
}
}
throw err("AUTH_INVALID", lastErr instanceof Error ? lastErr.message : "invalid token");
};
取密钥候选的地方把两个环境变量都拉上,JWT_SECRET_PREV 为空时自动过滤掉:
ts
// 当前密钥 + 上一轮密钥
const secretsOf = (env: Bindings): string[] =>
[env.JWT_SECRET, env.JWT_SECRET_PREV].filter((s): s is string => Boolean(s));
轮换操作本身是可逐步照做的,用 wrangler secret put(Workers 的密钥不走代码仓库,走环境变量):
| 步骤 | 命令 / 操作 | 说明 |
|---|---|---|
| 1 | wrangler secret put JWT_SECRET_PREV 填入旧密钥 |
先把旧密钥挪到 PREV 槽,此时两把都能验签 |
| 2 | wrangler secret put JWT_SECRET 填入新密钥 |
新签发的 token 全部用新密钥;旧 token 仍能被 PREV 验过 |
| 3 | 观察 1~2 个 access 有效期(≥1h) | 确认线上无异常、旧 token 基本自然过期 |
| 4 | wrangler secret put JWT_SECRET_PREV 填入空串或删掉 |
彻底废弃旧密钥,轮换完成 |
关键就在第 1 步:先让 PREV 顶上旧密钥,再换 JWT_SECRET。这段重叠期内,新旧 token 都能验,用户无感知。等旧 token 自然过期得差不多了,再清掉 PREV。整套流程零停机。
痛点二:怎么让旧 token「瞬间失效」
这是无状态 JWT 最反直觉的地方:token 发出去就不在服务端手里了,根本没有"删 token"这回事。 那用户改了密码、或者点了"退出所有设备",难道要让那台丢了的手机还能用旧 token 访问 7 天?
我的方案是 token 版本号(tv)+ KV 比对。

登录签发时,把用户当前的版本号写进 token:
ts
// routes/auth.ts ------ 登录签发,tv 来自 KV
const tv = await getTv(c.env.KV, user.id);
const accessToken = await signToken(
{ sub: user.id, tid: user.tenantId, role: user.role, type: "access", jti: crypto.randomUUID(), tv, mrp },
c.env.JWT_SECRET,
JWT.ACCESS_TTL,
);
每次请求,中间件拿 token 里的 tv 跟 KV 里的最新值比,对不上就失效:
ts
// middleware/auth.ts ------ tv 比对实现全设备登出
const tv = await getTv(c.env.KV, claims.sub);
if (tv !== claims.tv) throw err("AUTH_EXPIRED", "token superseded");
而"退出所有设备"和"改密码",本质都是把 KV 里的版本号自增一下:
ts
// routes/auth.ts ------ 退出所有设备
authRoutes.post("/logout-all", authMiddleware, async (c) => {
const user = c.get("user");
await incrTv(c.env.KV, user.id); // 版本号 +1,所有旧 token 的 tv 立即对不上
return ok(c, { success: true });
});
incrTv 写在 KV 封装层里:
ts
// lib/kv.ts ------ tv 自增(全设备登出)
export const incrTv = async (kv: KVNamespace, userId: string): Promise<number> => {
const next = (await getTv(kv, userId)) + 1;
await setTv(kv, userId, next);
return await getTv(kv, userId); // 写后读,返回实际值
};
同一套机制,"改密码"也会触发 incrTv,所以旧密码换完,所有设备的会话一并失效------这正是用户期望的行为。
这里有个单设备 vs 全设备的粒度区分,别混了:
| 失效手段 | 存储介质 | 粒度 | 触发场景 |
|---|---|---|---|
bl:<jti> 黑名单 |
KV,TTL=剩余有效期 | 单设备/单 refresh | 单设备退出登录、refresh 换发时吊销旧 jti |
tv:<userId> 版本号 |
KV,无 TTL 长期 | 全设备 | 改密码、退出所有设备 |
exp 自然过期 |
token 自带 | 单 token | 时间到了自动失效 |
简单说:jti 黑名单管"这一台",tv 版本号管"这个人"。 两者配合,单设备和全设备吊销都能精确控制。
两个真实踩过的边界坑
坑一:KV 没有 CAS,incrTv 不是严格原子的。
Cloudflare KV 不支持 compare-and-swap,高并发下"读-改-写"可能漏加。我的处理是写后读返回实际值,并在注释里写清楚了语义:并发下返回值"不小于实际值",宁可不精确也绝不会低估------宁可多踢一次,不能少踢导致该失效的 token 还活着。安全语义优先,计数精确性靠边。
坑二:强制改密别把用户逼进死局。
系统有个 mrp=1 强制改密标志(管理员重置密码后要求首登必改)。中间件里 mrp=1 会拦截除改密/登出外的所有接口,返回 423。但问题来了:如果强制改密还要求验"旧密码",而那个临时密码压根没传达给用户,用户就卡死在"过不了 423 又不知道旧密码"的死循环里。
所以我在 change-password 里做了豁免:强制改密场景下免验旧密码。这样管理员重置后,用户拿临时密码进来直接设新密码即可,不会被自己的安全机制关在门外。安全设计最怕这种"为了安全反而把人锁外面"的副作用,这个细节值得记一笔。
小结
回过头看,这套认证设计里真正扛事的就是三样东西:
- 双密钥候选 + PREV 槽位------让密钥轮换零停机;
- tv 版本号 + KV 比对------让无状态 JWT 也能"主动踢人";
- jti 黑名单------在 tv 之上补一层单设备精度。
它们都不复杂,但每一个都对应一个真实会发生的故障场景。架构决策不是比谁花哨,是比谁把边界情况想得周全。各位看官如果也在 Workers 上做认证,这套可以直接抄,密钥和版本号这两把锁,建议从第一天就上。
发财的小手点个小赞,咱们下一篇接着聊多租户的数据隔离。
相关阅读
- Node 后端实战 · 为什么用 Cloudflare Workers + D1 扛起了整个多租户 SaaS 后端?架构决策全景复盘
- Node 后端实战 · Cloudflare Workers 踩坑实录:TOML、D1 默认 local、CORS 与部署排障
- Node 后端实战 · D1 那些坑:100 参数上限逼出的批量写入重构
- Mac 本地部署 AI 生图:从零跑通 FLUX 与 Z-Image 的完整步骤(附完整脚本)
- NodeJS Koa 后端用户会话管理,JWT, Session,长短Token,本文一次性讲明白
- node 后端和浏览器前端,有关 RSA 非对称加密的完整实践, 前后端匹配的代码演示
- Nodejs 实现 Mysql 数据库的全量备份的代码演示
- 安装和配置 Nginx 和 Mysql ------ 一步一步配置 Ubuntu Server 的 NodeJS 服务器详细实录6
- PVE 虚拟机安装 Ubuntu Server V24 系统 ------ 一步一步安装配置基于 Ubuntu Server 的 NodeJS 服务器详细实录1
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!