jwtsecurityauthentication
2026年JWT安全最佳实践
· Cosyslabs
JWT安全漏洞始终位居API漏洞榜首。使用RS256或ES256算法,明确拒绝 none 算法,将访问令牌有效期设置为15分钟以内,实施刷新令牌轮换,并在信任任何声明之前始终在服务器端验证签名。
什么是JWT?
JSON Web Token由三个Base64URL编码的段组成,以点分隔:
头部.载荷.签名
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature
- 头部:算法和令牌类型
- 载荷:声明(用户数据、过期时间等)
- 签名:令牌未被篡改的加密证明
签名是您必须验证的内容。没有经过验证的签名的JWT只是未经验证的JSON。
关键漏洞:算法混淆
none 算法攻击
早期的JWT库接受头部中的 alg: "none",意味着不需要签名。攻击者可以伪造任何载荷:
{
"alg": "none",
"typ": "JWT"
}
没有签名检查,他们可以声称是任何用户。修复方法:
// Node.js — 始终明确指定允许的算法
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
// 永远不允许 "none"
// 永远不要使用 algorithms: ["RS256", "none"] — 这是一个漏洞
RS256 vs HS256 混淆攻击
HS256(HMAC)使用共享密钥——同一个密钥用于签名和验证。RS256(RSA)使用密钥对——私钥签名,公钥验证。
攻击:如果一个库看到 alg: "HS256" 并将RS256公钥用作HMAC密钥,获得了公钥(它是公开的!)的攻击者就可以伪造令牌。
始终在服务器端固定算法。永远不要从令牌中读取 alg 来决定如何验证它。
// 有漏洞 — 从令牌读取 alg
function verify(token, key) {
const { alg } = decodeHeader(token);
return verifyWith(token, key, alg); // 永远不要这样做
}
// 安全 — 算法在服务器端硬编码
function verify(token) {
return jwt.verify(token, PUBLIC_KEY, { algorithms: ["RS256"] });
}
算法建议
| 算法 | 类型 | 使用场景 |
|---|---|---|
| ES256 | 非对称(ECDSA) | 新项目的最佳选择——签名小、速度快 |
| RS256 | 非对称(RSA) | 广泛支持,适合互操作性 |
| HS256 | 对称(HMAC) | 仅当密钥真正共享且从未暴露时 |
| 无 | 永远不要使用 |
对于多个服务验证令牌的公共API,使用非对称算法(ES256/RS256)。私钥保留在认证服务器上;所有其他服务只持有公钥。
令牌过期和刷新策略
短期访问令牌限制了令牌被盗的损害。使用双令牌模式:
- 访问令牌:5–15分钟后过期,随每个API请求发送
- 刷新令牌:7–30天后过期,安全存储,仅用于获取新的访问令牌
// 颁发令牌
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
PRIVATE_KEY,
{ algorithm: "ES256", expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ sub: user.id, jti: crypto.randomUUID() },
REFRESH_SECRET,
{ expiresIn: "7d" }
);
刷新令牌轮换
每次使用刷新令牌时,使其失效并颁发新令牌。如果检测到被盗的刷新令牌被使用了两次,则使整个会话失效:
async function refreshTokens(oldRefreshToken) {
const payload = jwt.verify(oldRefreshToken, REFRESH_SECRET);
// 检查令牌之前是否已使用(重用检测)
const tokenRecord = await db.refreshTokens.findOne({ jti: payload.jti });
if (!tokenRecord || tokenRecord.used) {
// 检测到令牌重用 — 撤销整个家族
await db.refreshTokens.revokeFamily(payload.sub);
throw new Error("检测到令牌重用");
}
// 标记为已使用
await db.refreshTokens.markUsed(payload.jti);
// 颁发新的令牌对
return issueTokenPair(payload.sub);
}
安全存储JWT
| 存储位置 | XSS风险 | CSRF风险 | 建议 |
|---|---|---|---|
localStorage | 高 | 无 | 永远不用于认证令牌 |
sessionStorage | 高 | 无 | 永远不用于认证令牌 |
| 内存(JS变量) | 低 | 无 | 适合访问令牌 |
HttpOnly Cookie | 无 | 中 | 最适合刷新令牌 + CSRF令牌 |
将访问令牌存储在JavaScript内存中。将刷新令牌存储在 HttpOnly、Secure、SameSite=Strict Cookie中。
// 将刷新令牌设置为HttpOnly Cookie
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
path: "/auth/refresh", // 仅发送到刷新端点
});
始终验证的声明
除了验证签名外,还要验证这些标准声明:
jwt.verify(token, PUBLIC_KEY, {
algorithms: ["ES256"],
issuer: "https://auth.yourdomain.com", // iss 声明
audience: "https://api.yourdomain.com", // aud 声明
// exp 自动检查
// nbf 自动检查
});
| 声明 | 含义 | 始终验证 |
|---|---|---|
exp | 过期时间 | 是 |
nbf | 生效时间 | 是 |
iss | 颁发者 | 是 |
aud | 受众 | 是 |
sub | 主体(用户ID) | 是,与会话匹配 |
jti | JWT ID | 是,用于撤销 |
JWT撤销
JWT是无状态的——有效令牌在过期前保持有效。对于即时撤销(注销、密码更改、账户暂停),维护一个阻止列表:
// 注销时
await redis.setex(`revoked:${payload.jti}`, tokenTtlSeconds, "1");
// 每次请求时
async function verifyToken(token) {
const payload = jwt.verify(token, PUBLIC_KEY, { algorithms: ["ES256"] });
const isRevoked = await redis.exists(`revoked:${payload.jti}`);
if (isRevoked) throw new Error("令牌已撤销");
return payload;
}
调试JWT
使用JWT解码工具检查头部和载荷,无需将令牌发送到外部服务。所有解码都在您的浏览器中进行。
安全检查清单
- 使用ES256或RS256——公共API永远不要用HS256
- 在库配置中明确拒绝
alg: "none" - 在服务器端固定算法——永远不要从令牌头部读取
- 将访问令牌有效期设置为5–15分钟
- 实施带有重用检测的刷新令牌轮换
- 将刷新令牌存储在HttpOnly Cookie中,访问令牌存储在内存中
- 在每次请求时验证
iss、aud、exp、nbf - 实施基于JTI的撤销,用于注销/密码更改
- 永远不要记录完整的JWT——它们是持有者凭据
更多Cosyslabs工具
- Rough Estimator — 估算实施JWT认证、刷新令牌轮换和安全API层的开发工作量和成本。
- Routine Toolkit — 日常工具,包括贷款计算器、日期计算器和字数统计。
- Cosyslabs — Dev Tools !、PDF Convert All、Unit Convert All等产品背后的工作室。