JWT 安全攻防实战:5 大常见漏洞与生产环境加固指南

深入解析 JWT 常见安全漏洞,包括 alg:none 攻击、密钥混淆、Token 泄漏等,提供完整的攻防代码示例和生产环境安全加固方案。

安全与密码 2026-06-07 12 分钟

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(对称签名),密钥强度就是整个系统的安全上限。很多开发者为了方便,使用 secret123456password 这样的弱密钥。

实测数据:

密钥长度 爆破时间(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;
}

⚠️ 警告: localStoragesessionStorage 对 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 实现进行安全测试:

  1. 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
    
  2. jsonwebtoken 库的安全配置审计 — 检查你的代码中是否正确使用了 algorithms 参数

  3. 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 安全不是一个「设置了就忘」的事情。它需要持续的关注和审计。回顾本文的核心要点:

  1. 永远显式指定算法 — 不要让 Token 自己决定验证方式
  2. 密钥要足够强 — HS256 至少 32 字节随机密钥
  3. Token 过期时间要短 — 15 分钟是最佳实践
  4. 关键权限要二次校验 — 不要盲目信任 Payload
  5. 使用 Refresh Token 机制 — 实现安全的长期会话

最后,记住一句话:JWT 的安全性不取决于你用了多复杂的算法,而取决于你的验证逻辑有多严谨。 一个配置不当的 RS256 比一个正确配置的 HS256 更危险。

关键结论: JWT 安全的核心不是算法选择,而是验证逻辑的完整性。每次 jwt.verify() 调用都必须显式指定 algorithmsissueraudience 参数,永远不要信任 Token 自己声明的内容。


相关工具推荐:

📚 相关文章