JWT 深入与安全:签名算法、生命周期与撤销权衡
本文目录
JWT 经常被当成"比 session 更先进"的方案直接上生产,然后撞上它的三个固有约束:令牌一旦签发在有效期内基本无法作废、载荷只是 Base64URL 谁都能读、签名算法字段本身是攻击面。这篇文章从结构讲起:算法选择、claims 校验、生命周期与撤销的权衡、存储位置、经典攻击与密钥轮换。每一条都对应可验证的检查项。
三段结构:先看清它是什么
一个 JWT 由三个 Base64URL 片段用点号连接:
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjM0In0 . <签名段>
└──── header ────┘ └─── payload ────┘ └─ signature ─┘
上面是拆开的示意形态;真实 JWT 的三段之间没有空格,直接用点号连接。这里有意不写完整示例串:任何人都不该养成往文档里粘贴可解析令牌的习惯,教程文档也不该例外。
- header:
{"alg":"HS256","typ":"JWT"},声明签名算法。 - payload:claims 集合,标准的有
sub(主体)、exp/nbf/iat(时间三件套)、iss/aud(签发者/受众)、jti(令牌唯一 ID,撤销时要用它)。 - signature:对前两段用 header 声明的算法计算出的签名。
第一个必须刻在脑子里的边界:前两段只是编码,不是加密,任何人都能解开看。把手机号、权限明细、内部标识塞进 payload,等于把它们写进一张明信片。JWT 保证的是"签发者写了这些内容且未被篡改",不保证"内容保密"。需要保密就先做 JWE 加密,或干脆别放敏感数据。
签发侧的参考姿势(用 jose 库,字段与 TTL 全部显式):
import { SignJWT, importPKCS } from "jose";
async function issueAccessToken(user: User, keyPem: string): Promise<string> {
const key = await importPKCS(keyPem, "ES256");
return new SignJWT({
sub: user.id,
ver: user.tokenVersion, // 自定义 claim:版本号,撤销用
scope: "orders:read", // 权限粒度给窄不给宽
})
.setProtectedHeader({ alg: "ES256", kid: CURRENT_KID }) // kid 供校验方选公钥
.setIssuer("https://auth.example.com")
.setAudience("orders-api")
.setIssuedAt()
.setJti(crypto.randomUUID()) // 撤销粒度的锚点
.setExpirationTime("10m")
.sign(key);
}
注意签发时也要守住边界:claims 只放身份与版本,不放权限全量与个人信息;setProtectedHeader 里的 alg 必须是写死的选择,不是从请求里读来的值。
HS256 vs RS256/ES256:一个密钥的经济学问题
| 算法 | 密钥形态 | 签发 | 校验 | 密钥泄露影响 |
|---|---|---|---|---|
| HS256 | 对称,签名校验同一密钥 | 快 | 快 | 任何校验方都能伪造令牌(校验方也可签发) |
| RS256 | 非对称,私钥签名公钥校验 | 较快 | 慢一点(公钥运算) | 只泄露签名能力,公钥分发无风险 |
| ES256 | 非对称,ECDSA P-256 | 快,签名更短 | 快 | 同上,且密钥体积更小 |
选型看分发结构:
- 单体应用、一个服务自己签自己验:HS256 足够,运维最简单,性能最好。
- 多个服务需要校验令牌:必须 RS256/ES256。校验方只拿公钥,公钥泄露不构成伪造风险;如果用 HS256,每个校验服务都持有能签发令牌的密钥,攻击面随服务数量线性放大。
- 公钥分发用 JWK Set 端点(
/.well-known/jwks.json),校验方按kid取对应公钥并缓存——这是密钥轮换的基础设施(见后文)。
Node.js 里生成非对称密钥对只需要一条命令,密钥用环境变量或密钥管理服务注入,不要进代码库:
# 生成 ES256 密钥对(比 RSA 密钥短,性能在多数 Node 环境更好)
openssl ecparam -name prime256v1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
claims 校验清单:exp/nbf/iat/aud/iss 一项不能少
校验 JWT 不是"解开看 sub 就行"。成熟库默认覆盖大部分项,但必须显式配置期望值,而不是依赖默认宽松:
| Claim | 必须校验的内容 | 校验失败意味着 |
|---|---|---|
| exp | 当前时间 < exp | 过期令牌,多数库默认校验,禁用默认校验是高危配置 |
| nbf | 当前时间 >= nbf | "生效前"令牌,常被忽略,应校验 |
| iat | 存在且是数字;如做重放控制可限制与当前时间偏差 | 拒绝伪造时间戳 |
| iss | 精确等于配置的签发者 | 令牌来自别的签发方(多租户场景致命) |
| aud | 包含本服务的 audience | 令牌是发给别的服务的,跨服务混用 |
| sub | 存在且类型正确 | 主体缺失的令牌不可用于授权 |
import { jwtVerify } from "jose";
const { payload } = await jwtVerify(token, publicKey, {
issuer: "https://auth.example.com", // 显式期望,不靠默认
audience: "orders-api",
clockTolerance: 30, // 秒级时钟容差,跨机部署必备
algorithms: ["ES256"], // 白名单,详见"算法混淆攻击"
requiredClaims: ["exp", "iss", "aud", "sub"],
});
clockTolerance 是容易被忽视的实践项:多台机器时钟有秒级偏差时,没有容差会出现随机校验失败。容差设 30–60 秒即可,不要设成分钟级以上——那是在给过期令牌续命。
多服务校验方还有一条常见缺陷:手工把公钥复制进每个服务的配置。公钥一复制就产生了 N 份可能不同步的副本,轮换时漏一个服务就是全线故障。正确做法是校验方按 kid 从 JWK Set 端点动态取公钥并缓存:
import { createRemoteJWKSet, jwtVerify } from "jose";
// 校验方启动时构建远程 JWK Set,库会按 header.kid 自动匹配并缓存公钥
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
async function verifyAccessToken(token: string) {
return jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "orders-api",
algorithms: ["ES256"],
clockTolerance: 30,
});
}
这样密钥轮换对校验方是透明的:JWK Set 里出现新 kid,校验自动切换,不需要发版。
生命周期:短 TTL + refresh,还是长 TTL + 撤销
JWT 的根本权衡在这里:签名校验是离线的,服务端不查库就无法知道令牌是否被撤销。
| 模式 | access TTL | 撤销能力 | 复杂度 |
|---|---|---|---|
| 长 TTL 单令牌 | 数小时到数天 | 几乎没有,只能等过期 | 低,但泄露窗口大 |
| 短 TTL + refresh token | 5–15 分钟 | 撤销 refresh 即可掐断续期 | 中,需要刷新流程 |
| 短 TTL + 撤销检查 | 5–15 分钟 | 每次请求查黑名单 | 中高,校验从无状态变有状态 |
短 TTL + refresh 是多数系统的平衡点:access token 泄露的危害窗口被限制在几分钟内;refresh token 是服务端可撤销的长命资产,集中做存储与风控。如果每个 access token 请求都要查一次数据库黑名单,"无状态"的优势已经没有了——这时不如直接回 session 方案,语义更简单。
刷新侧的实现要点是静默刷新(silent refresh):SPA 在 access token 剩余寿命过半或收到 401 时,用 refresh token 换新令牌,期间不打断用户操作。失败(refresh token 过期或被撤销)才跳登录页。把"跳登录"从每 10 分钟一次降为真正的会话终点,用户体验与安全的冲突就解开了。
撤销的三种方案与适用场景
| 方案 | 原理 | 适用 | 代价 |
|---|---|---|---|
| 黑名单(deny list) | 撤销时把 jti(令牌 ID)写入 Redis,TTL 等于令牌剩余寿命 | 单个令牌精确撤销(改密码后踢当前会话) | 每次校验多一跳网络 |
| 版本号 | 用户表存 token_version,claims 里带版本,校验时比对 | 整用户级撤销(改密码踢全部设备) | 仍需查库或缓存用户版本 |
| 短 TTL 自然过期 | 不做主动撤销,靠 5 分钟内自然失效 | 一般业务令牌 | 注销后最多还能用到过期 |
黑名单方案的关键是 TTL 必须等于令牌剩余寿命——令牌过期后黑名单条目自动消失,不用人工清理,也没有无限增长问题:
// 撤销:登出或封禁时把 jti 写入 Redis,TTL 对齐令牌剩余时间
async function revokeToken(claims: Claims): Promise<void> {
const ttl = Math.max(1, Math.floor(claims.exp - Date.now() / 1000)); // 剩余秒数
await redis.set(`deny:${claims.jti}`, "1", { EX: ttl });
}
// 校验:验签之后补一次黑名单查询(只在高敏接口启用,普通接口吃短 TTL)
async function verifyWithDenyList(token: string): Promise<Claims> {
const claims = await verifyAccessToken(token);
const denied = await redis.exists(`deny:${claims.jti}`);
if (denied) throw new UnauthorizedError("token revoked");
return claims;
}
// 版本号撤销:签发时把用户当前版本写进 claims
function signAccessToken(user: User): string {
return sign({ sub: user.id, ver: user.tokenVersion, jti: uuid() }, key, { expiresIn: "10m" });
}
// 校验时比对版本,改密码或"全部下线"只需 user.tokenVersion += 1
async function verifyWithVersion(token: string): Promise<Claims> {
const claims = await verify(token);
const current = await cache.getUserTokenVersion(claims.sub);
if (claims.ver !== current) throw new UnauthorizedError("token revoked"); // 整代失效
return claims;
}
实践组合:常规登出走短 TTL 自然过期 + 撤销 refresh token;高敏感变更(改密码、改手机号)做版本号全量踢出;管理后台的封禁走黑名单。三类场景各用最便宜的机制,而不是为最坏情况把所有校验都变成查库。
存储位置:localStorage 还是 Cookie
前端放令牌有两个主流选项,各自的安全边界:
| 维度 | localStorage | httpOnly Cookie |
|---|---|---|
| XSS 能否读取 | 能(任何注入脚本直接拿走) | 不能(JS 不可读) |
| CSRF 能否被利用 | 需要代码主动带头,天然无 CSRF | 会自动携带,需 CSRF 防御(SameSite/CORS) |
| 跨域携带 | 可控(显式放头部) | 受 SameSite 与域限制 |
| 过期能力 | JS 自己管 | Cookie 属性自动管 |
规则:SPA 里 access token 放内存(页面刷新丢失,用 silent refresh 恢复),refresh token 放 httpOnly + Secure + SameSite=Strict 的 Cookie。这样 XSS 偷到的只有短命内存令牌,CSRF 又被 SameSite 挡住。Cookie 方案必须显式设 SameSite;依赖旧浏览器的默认行为(Lax/None 混乱时代)是典型的"配置漂移"漏洞。localStorage 存 refresh token 是应当出现在安全评审否决项里的做法。
服务端下发刷新 Cookie 时,属性要写全,缺一个属性就退化一类防御:
// 刷新端点:refresh token 走 httpOnly Cookie,前端 JS 完全接触不到
res.cookie("refresh_token", newRefreshToken, {
httpOnly: true, // JS 不可读:挡 XSS 窃取
secure: true, // 仅 HTTPS 传输
sameSite: "strict", // 挡跨站 CSRF
path: "/auth/refresh", // 只在刷新端点携带,缩小暴露面
maxAge: 7 * 24 * 3600 * 1000, // 与服务端存储的 TTL 保持一致
});
path 限定为刷新端点是容易被忽略的细节:Cookie 只随该路径的请求发送,其他接口(尤其第三方资源请求)都不会把它带出去,把 CSRF 的入口面积压到最小。
算法混淆攻击:none、跨算法替换与 kid 注入
header 的 alg 是攻击者可写的字段,围绕它有三类经典攻击:
- none 攻击:早期库允许
alg: "none",攻击者把算法改成 none、签名段置空,库就"校验通过"了。现代库已默认拒绝 none,但对策必须显式:校验时传入算法白名单(如上例algorithms: ["ES256"]),永远不要解析 header 里的 alg 来决定用什么验签。 - 跨算法替换:服务端用 RS256,攻击者伪造
alg: "HS256"并把服务端公开的 RSA 公钥当 HMAC 密钥签名。如果验签逻辑按 header 选算法,混淆就成功。对策同上:算法白名单 + 密钥类型校验,一条代码解决。 - kid 注入:
kid(密钥 ID)用于选择多密钥场景的验证密钥。如果 kid 直接拼进 SQL 或文件路径查密钥,攻击者可以注入路径穿越或 SQL。对策:kid 只用于查找内置 JWK 集合,白名单枚举,绝不拼 SQL/路径。
这三类攻击在 RFC 8725(JOSE 安全注意事项)里都有记载,属于"读一遍就能防住,不读就会踩"的类别。验签代码应当短到可以整体评审:取 token、按白名单算法验签、按白名单 claims 校验,任何"灵活"都是嫌疑。
密钥轮换流程
轮换不是换文件重启那么简单,在飞的令牌还要验。标准流程:
- 生成新密钥对,分配新
kid,把公钥加入 JWK Set(先发布公钥,再开始用私钥签发——保证校验方永远先拿到公钥)。 - 签发方切换到新
kid签名;旧令牌仍在 TTL 内流通,JWK Set 同时保留新旧公钥。 - 等旧密钥签发的令牌全部过期(最短一个旧 TTL 周期),从 JWK Set 移除旧公钥。
// JWK Set 端点:返回当前所有可用于校验的公钥
app.get("/.well-known/jwks.json", (_req, res) => {
res.json({
keys: [
currentJwk, // kid: "2026w43",签发中
previousJwk, // kid: "2026w40",仅校验,轮换宽限期结束即移除
],
});
});
轮换周期建议:常规 3–6 个月一轮;疑似泄露立即执行上述流程并把旧密钥私钥作废。轮换前要回答的可验证问题:所有校验方是否都实现了按 kid 取 JWK?没有 JWK 端点的校验方就是轮换的阻塞项——先用静态公钥部署的系统,上线第一天就该把"密钥从哪来"写进运维文档。
轮换日的执行顺序必须能机械验证:
- 新公钥出现在 JWK Set(用
curl断言包含新旧两个 kid)。 - 切换签发密钥(观察服务日志确认新令牌的 kid 已是新的)。
- 宽限期结束(一个旧 TTL 周期后),从 JWK Set 移除旧公钥并归档旧私钥。
顺序颠倒的后果是明确的:先撤旧公钥再切签发,TTL 内的旧令牌全部校验失败,等于一次自我攻击。把这三步做成脚本而不是 runbook 里的人肉步骤,是轮换可靠性的决定因素。
注意事项与边界
- 不要在 JWT 里放权限明细列表(permissions 数组):权限变了令牌还在飞,管理员被降权后几分钟内仍有旧权限。令牌只放身份与版本号,权限每次查缓存。
- 不要把 JWT 当 session 的替代品塞业务状态:购物车内容、草稿、多步流程的中间态放令牌里,令牌变大、变难撤销、变难迁移,这些状态本该在服务端。
- 多受众(一个令牌多个服务共用)要靠
aud数组与各服务显式校验隔离,且不要在给前端的令牌里夹带服务间通信的 claims。 - JWT 的体积比 session ID 大一个量级:每个请求都携带,长 claims 会推高带宽与解析 CPU,保持 claims 精简是工程要求不是洁癖。
- 日志与错误信息不得输出 token 全文与密钥;排障需要时只记 token 前缀与 kid。
- 所有时间 claims 依赖时钟:NTP 不准时,exp/nbf 校验会集体失真,服务器时钟同步是 JWT 体系的隐含依赖。
总结
JWT 的价值是无状态的分布式校验,代价是撤销难、载荷明文、算法字段即攻击面。工程上的正确姿势是一个组合:非对称算法与 JWK 分发支撑多服务校验,短 TTL + refresh token + 版本号撤销平衡安全与可用性,显式算法白名单与 claims 校验清单堵住混淆攻击,定期密钥轮换保留应急能力。把它当作"带边界的工具"来配置,而不是"先进方案"来信仰,它就足够可靠。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。