2025 年 OWASP 发布的 API Security Top 10 报告中,Broken Authentication(认证失效) 连续三年位列前三。而在现代 Web 应用中,JSON Web Token(JWT)几乎无处不在——从单点登录(SSO)到移动端 API 认证,JWT 已成为事实标准。然而,大多数开发者只学会了如何「使用」JWT,却从未真正理解它的攻击面。
本文不是一篇「什么是 JWT」的入门科普。我将从攻击者视角出发,逐一拆解 5 种真实生产环境中最常见的 JWT 漏洞,每个漏洞都附带可运行的攻防代码,并最终给出一套可以直接落地的安全加固清单。
🔐 一、JWT 结构回顾与攻击面分析
在讨论漏洞之前,有必要快速回顾 JWT 的三段式结构。一个典型的 JWT 长这样:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.K7g9aEfGxVJ3FnfmFhOeR3IFIFz4pJLMpQIXW4UJa5A
它由三部分组成,以 . 分隔:
| 部分 | 内容 | 编码方式 | 可篡改? |
|---|---|---|---|
| Header | 算法类型、Token 类型 | Base64URL | ⚠️ 可篡改 |
| Payload | 用户数据(userId, role 等) | Base64URL | ⚠️ 可篡改 |
| Signature | 对 Header+Payload 的签名 | HMAC/RSA/ECDSA | ✅ 理论上不可篡改 |
⚠️ 警告: Base64URL 不是加密!任何人拿到 JWT 都能解码 Header 和 Payload。永远不要在 JWT Payload 中存放敏感信息(密码、身份证号等)。
攻击者的目标很明确:在不持有密钥的情况下,构造一个服务器会接受的 JWT。实现这个目标的路径比你想象的多得多。
1.1 JWT 的信任模型
JWT 的安全性完全依赖于签名验证。服务器信任任何签名通过的 Token。这意味着:
- ✅ 如果签名算法正确、密钥足够强,JWT 是安全的
- ❌ 如果验证逻辑有任何疏漏,整个认证体系就会崩塌
关键问题在于:JWT 规范过于灵活,给了攻击者太多可操作的空间。
⚔️ 二、5 大常见漏洞与攻防实战
2.1 漏洞一:alg:none 攻击
这是最经典、最简单的 JWT 攻击。JWT Header 中的 alg 字段指定了签名算法,而某些库在验证时会直接信任 Header 中声明的算法。
攻击原理: 攻击者将 alg 设置为 none,去掉签名部分,服务器如果接受这个 Token,攻击就成功了。
// ❌ 错误:攻击者构造的 alg:none Token
const header = { alg: "none", typ: "JWT" };
const payload = { userId: 1, role: "admin" };
// Base64URL 编码
const encode = (obj) => Buffer.from(JSON.stringify(obj))
.toString("base64url");
const fakeToken = `${encode(header)}.${encode(payload)}.`;
console.log(fakeToken);
// eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.
这个 Token 没有签名,但 Header 声明了 alg: "none"。如果服务器端的 JWT 库没有强制检查算法,就会直接接受它。
// ✅ 正确:服务器端必须强制指定允许的算法
const jwt = require("jsonwebtoken");
// ❌ 危险写法:算法来自 Token Header
// jwt.verify(token, secret); // 默认会信任 Token 中的 alg
// ✅ 安全写法:强制指定算法白名单
const decoded = jwt.verify(token, secret, {
algorithms: ["HS256"], // 只允许预期的算法
});
console.log(decoded);
// { userId: 1, role: 'admin' } — 正常验证
📌 记住: 永远不要让 Token 自己决定验证算法。在
verify()中显式指定algorithms参数。
2.2 漏洞二:密钥混淆攻击(RS256 → HS256)
这是 JWT 安全中最精妙的攻击之一。JWT 支持两类算法:
- 对称算法(HS256):用同一个密钥签名和验证
- 非对称算法(RS256):用私钥签名,用公钥验证
攻击原理: 如果服务器使用 RS256(公钥验证),攻击者将 Header 中的 alg 改为 HS256,然后用公钥作为 HMAC 密钥来签名。由于公钥通常是公开的,攻击者就能伪造合法 Token。
// ❌ 攻击脚本:利用密钥混淆漏洞
const crypto = require("crypto");
const fs = require("fs");
// 攻击者获取到的公钥(通常公开可得)
const publicKey = fs.readFileSync("public.pem");
const header = Buffer.from(JSON.stringify({
alg: "HS256", // 改为对称算法!
typ: "JWT"
})).toString("base64url");
const payload = Buffer.from(JSON.stringify({
userId: 1,
role: "admin"
})).toString("base64url");
// 用公钥作为 HMAC 密钥签名
const signature = crypto
.createHmac("sha256", publicKey)
.update(`${header}.${payload}`)
.digest("base64url");
const maliciousToken = `${header}.${payload}.${signature}`;
console.log("Malicious token:", maliciousToken);
// ✅ 正确:服务器端防御密钥混淆攻击
const jwt = require("jsonwebtoken");
const fs = require("fs");
const publicKey = fs.readFileSync("public.pem");
// 关键:强制指定 RS256,不允许降级到 HS256
const decoded = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // 只允许非对称算法
});
⚠️ 警告: 如果你使用 RS256,必须在验证时强制指定
algorithms: ["RS256"]。否则攻击者可以降级到 HS256 并用你的公钥伪造 Token。
2.3 漏洞三:Token 泄漏与重放攻击
JWT 一旦签发,在过期之前都是有效的。如果 Token 被泄漏(通过日志、URL 参数、浏览器历史等),攻击者可以直接使用它。
常见的泄漏路径:
- ❌ Token 放在 URL 参数中:
/api/data?token=eyJhbG...(会被记录在服务器日志、浏览器历史、Referer Header) - ❌ Token 存储在 localStorage(容易被 XSS 攻击窃取)
- ❌ 错误日志中打印了完整的 Token
- ❌ 前端代码中硬编码了 Token
// ❌ 危险:Token 放在 URL 参数中
fetch(`/api/user?token=${jwtToken}`);
// ✅ 安全:Token 放在 Authorization Header 中
fetch("/api/user", {
headers: {
Authorization: `Bearer ${jwtToken}`,
},
});
防御重放攻击的方案:
// ✅ 使用 jti(JWT ID)防止重放攻击
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
function generateToken(userId, secret) {
return jwt.sign(
{
userId,
jti: crypto.randomUUID(), // 唯一 Token ID
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 分钟过期
},
secret
);
}
// 服务端维护已使用的 jti 集合(用 Redis)
const usedJtis = new Set(); // 生产环境用 Redis,设置 TTL = Token 过期时间
function verifyToken(token, secret) {
const decoded = jwt.verify(token, secret);
// 检查 jti 是否已被使用
if (usedJtis.has(decoded.jti)) {
throw new Error("Token replay detected");
}
// 标记 jti 为已使用
usedJtis.add(decoded.jti);
return decoded;
}
💡 提示: Token 过期时间建议设为 15-30 分钟,配合 Refresh Token 机制实现长期会话。短过期时间能显著缩小泄漏窗口。
2.4 漏洞四:弱密钥爆破
如果你使用 HS256(对称签名),密钥强度就是整个系统的安全上限。很多开发者为了方便,使用 secret、123456、password 这样的弱密钥。
实测数据:
| 密钥长度 | 爆破时间(RTX 4090) | 安全性 |
|---|---|---|
| 8 字符 | < 1 秒 | ❌ 极不安全 |
| 16 字符 | ~2 小时 | ❌ 不安全 |
| 32 字符 | ~10^14 年 | ✅ 安全 |
| 64 字符 | 不可计算 | ✅ 极安全 |
// ❌ 危险:弱密钥
const token = jwt.sign({ userId: 1 }, "secret");
// ✅ 安全:使用 cryptographically strong 密钥
const crypto = require("crypto");
// 生成 256 位(32 字节)随机密钥
const strongSecret = crypto.randomBytes(32).toString("hex");
console.log("Strong secret:", strongSecret);
// 输出示例:a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
const token = jwt.sign({ userId: 1 }, strongSecret, {
algorithm: "HS256",
expiresIn: "15m",
});
📌 记住: HS256 密钥至少 32 字节(256 位),RS256 密钥至少 2048 位。使用
crypto.randomBytes()生成密钥,不要手写。
2.5 漏洞五:Payload 篡改与权限提升
如果服务器在验证 Token 签名后,没有对 Payload 中的字段做二次校验,攻击者可能通过其他手段(如上述漏洞)伪造 Payload,实现权限提升。
典型场景: 攻击者修改 Payload 中的 role 字段从 user 变为 admin。
// ❌ 危险:直接信任 Payload 中的 role 字段
app.get("/admin/dashboard", (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
// 直接使用 Payload 中的 role,没有二次校验
if (decoded.role === "admin") {
res.json({ message: "Welcome, admin!" });
}
});
// ✅ 安全:签名验证后,从数据库查询实际权限
app.get("/admin/dashboard", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
// 从数据库查询用户实际角色,不信任 Token 中的 role
const user = await db.users.findById(decoded.userId);
if (!user || user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
res.json({ message: "Welcome, admin!" });
});
⚠️ 警告: JWT Payload 中的数据不可信(即使签名通过)。关键权限判断必须从数据库或权威数据源二次确认。
🛡️ 三、生产环境安全加固方案
3.1 JWT 验证中间件最佳实践
把上面所有防御措施整合到一个可复用的中间件中:
// ✅ 生产级 JWT 验证中间件
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
function createJwtMiddleware(options = {}) {
const {
secret,
algorithms = ["HS256"],
issuer,
audience,
maxAge = "15m",
} = options;
// 密钥强度检查
if (algorithms[0]?.startsWith("HS")) {
const minKeyLength = 32;
if (Buffer.byteLength(secret) < minKeyLength) {
throw new Error(
`HS256 secret must be at least ${minKeyLength} bytes`
);
}
}
return (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing token" });
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, secret, {
algorithms,
issuer,
audience,
maxAge,
clockTolerance: 30, // 允许 30 秒时钟偏差
});
// 安全地附加用户信息,不传递整个 Payload
req.user = {
userId: decoded.userId,
// 不要直接传递 role 等敏感字段
};
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(401).json({ error: "Invalid token" });
}
};
}
// 使用示例
const authMiddleware = createJwtMiddleware({
secret: process.env.JWT_SECRET,
algorithms: ["HS256"],
issuer: "jsjson.com",
audience: "jsjson-api",
maxAge: "15m",
});
app.use("/api", authMiddleware);
3.2 Token 生命周期管理
一个完整的 Token 管理方案应该包含 Access Token + Refresh Token:
// ✅ 完整的 Token 刷新机制
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
class TokenManager {
constructor(config) {
this.accessSecret = config.accessSecret;
this.refreshSecret = config.refreshSecret;
this.accessExpiry = "15m";
this.refreshExpiry = "7d";
this.refreshStore = new Map(); // 生产环境用 Redis
}
// 生成 Token 对
generateTokenPair(userId) {
const accessToken = jwt.sign(
{ userId, type: "access" },
this.accessSecret,
{ expiresIn: this.accessExpiry, algorithm: "HS256" }
);
const refreshToken = jwt.sign(
{ userId, type: "refresh", jti: crypto.randomUUID() },
this.refreshSecret,
{ expiresIn: this.refreshExpiry, algorithm: "HS256" }
);
// 存储 Refresh Token(用于撤销)
this.refreshStore.set(refreshToken, {
userId,
createdAt: Date.now(),
});
return { accessToken, refreshToken };
}
// 刷新 Access Token
refresh(refreshToken) {
try {
const decoded = jwt.verify(refreshToken, this.refreshSecret, {
algorithms: ["HS256"],
});
// 检查 Refresh Token 是否被撤销
if (!this.refreshStore.has(refreshToken)) {
throw new Error("Refresh token revoked");
}
// 撤销旧的 Refresh Token(Rotation)
this.refreshStore.delete(refreshToken);
// 生成新的 Token 对
return this.generateTokenPair(decoded.userId);
} catch (err) {
throw new Error("Invalid refresh token");
}
}
// 撤销用户所有 Token
revokeAll(userId) {
for (const [token, data] of this.refreshStore) {
if (data.userId === userId) {
this.refreshStore.delete(token);
}
}
}
}
// 使用示例
const tokenManager = new TokenManager({
accessSecret: process.env.JWT_ACCESS_SECRET,
refreshSecret: process.env.JWT_REFRESH_SECRET,
});
// 登录
app.post("/api/login", async (req, res) => {
const user = await authenticateUser(req.body);
const tokens = tokenManager.generateTokenPair(user.id);
res.json(tokens);
});
// 刷新
app.post("/api/refresh", (req, res) => {
try {
const tokens = tokenManager.refresh(req.body.refreshToken);
res.json(tokens);
} catch (err) {
res.status(401).json({ error: "Invalid refresh token" });
}
});
3.3 安全配置清单
以下是生产环境 JWT 配置的完整检查清单:
| 检查项 | 要求 | 推荐 |
|---|---|---|
| 签名算法 | 显式指定,不信任 Token Header | ✅ HS256 或 RS256 |
| 密钥长度 | HS256 ≥ 32 字节,RS256 ≥ 2048 位 | ✅ 使用 crypto.randomBytes() |
| 过期时间 | Access Token ≤ 30 分钟 | ✅ 15 分钟 |
| Refresh Token | 7-30 天,支持 Rotation | ✅ 每次刷新都换新 |
| 存储位置 | HttpOnly Cookie 或内存 | ❌ 不要放 localStorage |
| 传输方式 | Authorization Header | ❌ 不要放 URL 参数 |
| iss/aud 校验 | 必须验证发行者和受众 | ✅ 防止 Token 跨服务使用 |
| jti 防重放 | 配合 Redis 使用 | ✅ 关键接口必须 |
| 密钥轮换 | 每 90 天轮换一次 | ✅ 使用密钥版本号 |
| 错误处理 | 统一返回 401,不泄露细节 | ✅ 不要区分"过期"和"无效" |
⚠️ 警告: 不要在错误响应中区分「Token 过期」和「Token 无效」。这会帮助攻击者判断 Token 是否有效。统一返回 401 即可。
💡 四、Token 存储安全:前端最容易犯的错
即使你的 JWT 实现完美无缺,错误的存储方式也会让一切努力付诸东流。前端 Token 存储是一个被严重低估的安全问题。
4.1 存储方案对比
| 存储方式 | XSS 风险 | CSRF 风险 | 推荐度 |
|---|---|---|---|
| localStorage | ❌ 高(可被 JS 读取) | ✅ 低 | ❌ 不推荐 |
| sessionStorage | ❌ 高(可被 JS 读取) | ✅ 低 | ❌ 不推荐 |
| HttpOnly Cookie | ✅ 低(JS 不可读) | ⚠️ 中(需防护) | ✅ 推荐 |
| 内存变量 | ✅ 低(页面刷新丢失) | ✅ 低 | ✅ 推荐(SPA) |
// ❌ 危险:localStorage 存储 Token
// 任何 XSS 漏洞都能直接窃取 Token
localStorage.setItem("token", accessToken);
// ✅ 安全:使用 HttpOnly Cookie 存储 Refresh Token
// 服务端设置 Cookie
res.cookie("refreshToken", refreshToken, {
httpOnly: true, // JavaScript 无法读取
secure: true, // 仅 HTTPS 传输
sameSite: "strict", // 防止 CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
path: "/api/refresh", // 限制 Cookie 作用域
});
// ✅ 安全:Access Token 存储在内存中(仅限 SPA)
// 页面刷新后通过 Refresh Token 重新获取
let accessToken = null;
function setAccessToken(token) {
accessToken = token; // 仅存储在内存变量中
}
function getAccessToken() {
return accessToken;
}
⚠️ 警告:
localStorage和sessionStorage对 XSS 攻击完全不设防。一旦页面存在任何 XSS 漏洞,攻击者可以轻松窃取所有存储在其中的 Token。
4.2 CSRF 防护策略
使用 HttpOnly Cookie 存储 Token 时,需要额外防护 CSRF 攻击:
// ✅ 双重 Cookie 防护 CSRF
// 服务端设置一个普通的 Cookie(可被 JS 读取)
res.cookie("csrf-token", crypto.randomUUID(), {
httpOnly: false, // JS 可读取
secure: true,
sameSite: "strict",
});
// 客户端在请求时带上 CSRF Token
fetch("/api/refresh", {
method: "POST",
credentials: "include", // 携带 Cookie
headers: {
"X-CSRF-Token": getCookie("csrf-token"), // 从 Cookie 读取并放入 Header
},
});
// 服务端验证:Cookie 中的 CSRF Token 与 Header 中的一致
function verifyCsrf(req) {
const cookieToken = req.cookies["csrf-token"];
const headerToken = req.headers["x-csrf-token"];
return cookieToken && cookieToken === headerToken;
}
💡 提示: CSRF 攻击可以携带 Cookie,但无法读取 Cookie。因此将 CSRF Token 放在 Cookie 中,请求时从 Cookie 读取并放入 Header,服务端验证两者一致即可防御。
🔧 五、JWT vs Session:何时该用哪个?
很多团队盲目选择 JWT,但 JWT 并不是银弹。以下是两者的对比:
| 维度 | JWT | Session |
|---|---|---|
| 状态 | 无状态 | 有状态(服务端存储) |
| 撤销难度 | 困难(需要黑名单) | 简单(删除即可) |
| 扩展性 | ✅ 天然支持分布式 | ⚠️ 需要共享存储 |
| 性能 | ✅ 无需数据库查询 | ❌ 每次请求查库 |
| 安全性 | ⚠️ 攻击面更大 | ✅ 攻击面更小 |
| 适用场景 | 微服务、移动端、SSO | 传统 Web 应用 |
💡 提示: 如果你的应用是单体架构、不需要跨服务认证,Session 反而更安全、更简单。不要为了「看起来现代」而盲目选择 JWT。
🔧 六、JWT 安全测试工具
在部署前,使用以下工具对你的 JWT 实现进行安全测试:
-
jwt_tool — 一个 Python 命令行工具,可以自动化测试各种 JWT 漏洞
# 安装 pip install pyjwt-toolkit # 测试 alg:none 攻击 python jwt_tool.py <token> -X a # 测试密钥爆破 python jwt_tool.py <token> -C -d wordlist.txt -
jsonwebtoken 库的安全配置审计 — 检查你的代码中是否正确使用了
algorithms参数 -
OWASP JWT Cheat Sheet — 官方的 JWT 安全最佳实践参考
6.1 常见 JWT 库的安全配置
不同语言的 JWT 库默认行为不同,以下是常见库的安全配置对照:
// Node.js - jsonwebtoken 库
const jwt = require("jsonwebtoken");
// ❌ 危险:不指定 algorithms
jwt.verify(token, secret);
// ✅ 安全:显式指定算法白名单
jwt.verify(token, secret, { algorithms: ["HS256"] });
# Python - PyJWT 库
import jwt
# ❌ 危险:不指定算法
decoded = jwt.decode(token, secret, options={"verify_signature": False})
# ✅ 安全:显式指定算法
decoded = jwt.decode(token, secret, algorithms=["HS256"])
// Java - jjwt 库
// ❌ 危险:允许所有算法
Jwts.parser().setSigningKey(key).parse(token);
// ✅ 安全:指定算法
Jwts.parserBuilder()
.setSigningKey(key)
.setAllowedClockSkewSeconds(30)
.build()
.parseClaimsJws(token);
📌 记住: 无论使用哪个语言的 JWT 库,都要检查其默认行为。很多库默认会信任 Token Header 中声明的算法,这正是攻击者利用的入口。
⚡ 总结
JWT 安全不是一个「设置了就忘」的事情。它需要持续的关注和审计。回顾本文的核心要点:
- 永远显式指定算法 — 不要让 Token 自己决定验证方式
- 密钥要足够强 — HS256 至少 32 字节随机密钥
- Token 过期时间要短 — 15 分钟是最佳实践
- 关键权限要二次校验 — 不要盲目信任 Payload
- 使用 Refresh Token 机制 — 实现安全的长期会话
最后,记住一句话:JWT 的安全性不取决于你用了多复杂的算法,而取决于你的验证逻辑有多严谨。 一个配置不当的 RS256 比一个正确配置的 HS256 更危险。
⚡ 关键结论: JWT 安全的核心不是算法选择,而是验证逻辑的完整性。每次
jwt.verify()调用都必须显式指定algorithms、issuer、audience参数,永远不要信任 Token 自己声明的内容。
相关工具推荐:
- 🔧 在线 JWT 解析器 — 快速解码 JWT Payload
- 🔧 RSA 密钥生成器 — 生成 RS256 密钥对
- 🔧 MD5/SHA 哈希工具 — 验证密钥强度