API 鉴权中如何防止 JWT Token 被窃取后的重放攻击?

文章导读
JWT 本身是无状态的,一旦签发且在有效期内,服务端默认信任所有携带该 Token 的请求。因此,单纯缩短有效期只能减小攻击窗口,无法在有效期内阻止重放。要彻底防止重放,必须引入服务端状态校验(如黑名单或 Nonce 机制)。
📋 目录
  1. 1. 完善 JWT 结构:引入 JTI 字段
  2. 2. 实现服务端黑名单中间件
  3. 3. 高危操作增加 Nonce 机制
  4. 4. Refresh Token 存储与绑定策略
  5. 5. 验证与排查步骤
  6. 常见工程坑
  7. 参考来源
A A

JWT 本身是无状态的,一旦签发且在有效期内,服务端默认信任所有携带该 Token 的请求。因此,单纯缩短有效期只能减小攻击窗口,无法在有效期内阻止重放。要彻底防止重放,必须引入服务端状态校验(如黑名单或 Nonce 机制)。

工程落地结论:生产环境必须结合短有效期、刷新令牌和服务端状态记录。

  • 基础防护:全站 HTTPS,Access Token 有效期控制在 15-30 分钟。
  • 核心机制:敏感业务必须实现 Token 黑名单或 Nonce 防重放,不能仅依赖有效期。
  • 刷新安全:Refresh Token 需绑定设备指纹,存储于服务端数据库,避免绑定 IP 导致用户网络切换时失效。
  • 验证手段:登出后立即复用旧 Token 应被拦截,日志中无异常重放记录。

1. 完善 JWT 结构:引入 JTI 字段

要实现 Token 黑名单,必须在 Token 中包含唯一标识符(JWT ID, jti)。之前的示例往往忽略此字段,导致无法精准定位单个 Token。

生成 JTI 的代码示例(Node.js):

API 鉴权中如何防止 JWT Token 被窃取后的重放攻击?
const crypto = require('crypto');

function generateJti() {
  // 使用 UUID v4 确保唯一性
  return crypto.randomUUID();
}

const payload = {
  sub: "1234567890",
  name: "John Doe",
  jti: generateJti(), // 关键字段,用于黑名单索引
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 900 // 15 分钟
};

注意:jti 必须在整个系统内唯一,且不可预测,防止攻击者遍历。

2. 实现服务端黑名单中间件

对于登出、权限变更或检测到异常的场景,需将未过期的 jti 加入 Redis 黑名单。以下是基于 Node.js Express 的中间件实现,比伪代码更具参考性。

const redis = require('redis');
const client = redis.createClient();

async function jwtAuthMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = verifyToken(token); // 假设的验签函数
    
    // 核心校验:检查 jti 是否在黑名单中
    const isBlacklisted = await client.exists(`blacklist:${payload.jti}`);
    if (isBlacklisted) {
      return res.status(401).json({ error: 'Token revoked' });
    }

    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// 登出接口示例
app.post('/logout', async (req, res) => {
  const token = req.body.token;
  const payload = verifyToken(token);
  const remainingTime = payload.exp - Math.floor(Date.now() / 1000);
  
  // 将 jti 存入 Redis,过期时间与 Token 剩余有效期一致
  if (remainingTime > 0) {
    await client.setEx(`blacklist:${payload.jti}`, remainingTime, '1');
  }
  res.json({ message: 'Logged out' });
});

架构提醒:引入 Redis 依赖后,需考虑 Redis 不可用时的降级策略(如仅记录日志不阻断,或本地缓存同步),避免单点故障导致全站无法登录。

API 鉴权中如何防止 JWT Token 被窃取后的重放攻击?

3. 高危操作增加 Nonce 机制

对于支付、修改密码等极高敏感接口,仅靠黑名单可能不够(因为黑名单通常是异步或登出时触发)。可引入 Nonce(一次性数字)机制。

实现流程:

API 鉴权中如何防止 JWT Token 被窃取后的重放攻击?
  1. 客户端请求前生成一个随机 Nonce。
  2. 请求头携带该 Nonce。
  3. 服务端检查 Redis 中是否存在该 Nonce。
  4. 若不存在,存入 Redis 并设置短过期时间(如 5 分钟),允许请求;若存在,拒绝请求(判定为重放)。
# Redis 检查逻辑伪代码
if (redis.exists("nonce:" + request.nonce)) {
    return 403 Forbidden; // 重放攻击
}
redis.setex("nonce:" + request.nonce, 300, "1");

4. Refresh Token 存储与绑定策略

Refresh Token 权限较大,泄露后果严重。常见的错误做法是将其绑定 IP,这会导致用户从 WiFi 切换到 4G 时被迫登出。

最佳实践:

  • 绑定设备指纹:结合 Device ID 和 User-Agent 生成哈希,作为 Refresh Token 的校验依据,避免直接绑定易变的 IP 地址。
  • 服务端存储:Refresh Token 应存储在服务端数据库(如 MySQL),客户端仅存 ID,每次刷新时比对数据库记录。
  • 轮换机制:每次使用 Refresh Token 换取新 Access Token 时,同时颁发新的 Refresh Token 并使旧失效(Rotation)。

5. 验证与排查步骤

部署后需通过以下场景验证防护是否生效:

  • 重放测试:使用 curl 捕获一个有效 Token,在 1 秒内重复发送 10 次。若开启了 Nonce 或单次使用限制,后续请求应失败。
  • 登出失效测试:调用登出接口后,立即使用旧 Token 请求受保护接口,应返回 401。
  • 时间同步检查:集群内服务器时间偏差应控制在 1 秒内,否则可能导致 Token 提前失效。建议配置 NTP 服务。
# 测试旧 Token 是否失效
curl -H "Authorization: Bearer <old_token>" https://api.example.com/user/info
# 预期返回:401 Unauthorized

常见工程坑

  • 性能损耗:每次请求都查 Redis 会增加延迟。对于高并发只读接口,可考虑本地缓存同步黑名单,或仅对写接口开启严格校验。
  • 时钟漂移:JWT 验证依赖服务器时间。如果负载均衡器后端服务器时间不一致,会导致验签失败。务必统一集群时间源。
  • 日志泄露:确保日志系统中脱敏 Token 内容,避免记录完整的 Authorization 头,防止日志泄露导致二次攻击。

参考来源

  • OWASP, "JSON Web Token Cheat Sheet", https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
  • IETF, "RFC 7519: JSON Web Token (JWT)", https://datatracker.ietf.org/doc/html/rfc7519