API 接口签名与防重放攻击实战:HMAC、时间戳与 Nonce 机制深度指南

深入讲解 API 请求签名的完整实现方案,涵盖 HMAC-SHA256 签名、时间戳防重放、Nonce 去重机制,附 Node.js 和 Java 完整代码,助你构建安全可靠的 API 通信层。

安全与密码 2026-06-02 15 分钟

根据 Salt Security 2025 年度报告,API 相关攻击同比增长 681%,其中参数篡改和重放攻击占据了攻击向量的 42%。如果你的 API 仅依赖 HTTPS 传输加密而没有请求签名机制,那么在内网环境、日志审计、第三方回调验证等场景下,你将面临严重的安全隐患。API 接口签名(Request Signing)是解决这些问题的核心技术方案,本文将从原理到生产级实现,完整拆解 HMAC 签名、时间戳校验和 Nonce 去重三大机制。

为什么 HTTPS 还不够?因为 HTTPS 只保证传输链路的安全,它无法解决以下三个问题:第一,服务端无法验证请求内容是否被中间代理篡改过(某些 CDN 或反向代理可能会修改请求体);第二,无法防止合法用户在有效会话内重复提交同一请求;第三,无法在离线场景(如日志回放、异步回调)中验证请求的完整性。这正是 API 签名存在的意义——它为请求提供了一个独立于传输层的「数字指纹」,即使请求被截获、存储、甚至延迟投递,签名依然能验证其真实性和时效性。

🔐 一、API 签名的核心原理与算法选型

API 签名的本质是一个不可逆的身份验证 + 完整性校验过程。客户端使用共享密钥对请求内容生成一段摘要(签名),服务端用同样的密钥和算法重新计算并比对。如果结果一致,说明请求未被篡改且来源可信。

1.1 三大签名算法对比

在实际项目中,最常用的签名算法有三种:HMAC-SHA256、RSA-SHA256 和 ECDSA-SHA256。它们的核心区别在于密钥模型和计算开销。

特性 HMAC-SHA256 RSA-SHA256 ECDSA-SHA256
密钥模型 对称(共享密钥) 非对称(公私钥) 非对称(公私钥)
签名速度 ⚡ 极快(~0.01ms) 🐢 较慢(~1ms) ⚡ 较快(~0.1ms)
签名长度 32 字节(固定) 256 字节(固定) 64 字节(固定)
安全强度
适用场景 内部服务间通信 开放平台 API 移动端 + IoT
密钥管理 需要安全分发共享密钥 公钥可公开分发 公钥可公开分发
推荐程度 ✅ 内部系统首选 ✅ 开放平台首选 ✅ 资源受限环境首选

💡 **提示:**对于大多数企业内部系统和微服务间通信,HMAC-SHA256 是最佳选择——实现简单、性能极高、安全性足够。只有在需要第三方验证签名(如开放平台)时,才需要考虑非对称算法。

1.2 规范化请求字符串(Canonical Request)

签名的第一步是将请求参数规范化为一个字符串。规范化的核心目标是:无论参数顺序如何,同一个请求永远生成相同的字符串

规范化字符串的标准格式如下:

HTTP_METHOD\n
URL_PATH\n
SORTED_QUERY_STRING\n
SORTED_HEADERS\n
BODY_HASH

下面是一个完整的规范化字符串构建函数:

// 构建规范化请求字符串(Canonical Request)
function buildCanonicalRequest(method, path, query, headers, body) {
  // 1. HTTP 方法转大写
  const httpMethod = method.toUpperCase();

  // 2. URL 路径(需要 URL 编码)
  const canonicalPath = encodeURIComponent(path).replace(/%2F/g, '/');

  // 3. 查询参数按 key 字母序排列
  const sortedQuery = Object.keys(query)
    .sort()
    .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`)
    .join('&');

  // 4. 参与签名的请求头按 key 小写字母序排列
  const signedHeaders = ['content-type', 'x-timestamp', 'x-nonce'];
  const canonicalHeaders = signedHeaders
    .sort()
    .map(key => {
      const value = (headers[key] || '').trim().replace(/\s+/g, ' ');
      return `${key}:${value}`;
    })
    .join('\n') + '\n';

  // 5. 请求体的 SHA256 哈希
  const crypto = require('crypto');
  const bodyHash = crypto
    .createHash('sha256')
    .update(body || '', 'utf8')
    .digest('hex');

  // 6. 拼接规范化字符串
  const canonicalRequest = [
    httpMethod,
    canonicalPath,
    sortedQuery,
    canonicalHeaders,
    signedHeaders.sort().join(';'),
    bodyHash
  ].join('\n');

  return canonicalRequest;
}

⚠️ 警告:规范化字符串中不要包含 Host、Authorization、User-Agent 等代理层可能修改的请求头。否则经过 Nginx 或 API 网关转发后,签名验证必然失败。这是新手最常踩的坑。

1.3 HMAC-SHA256 签名的完整流程

掌握了规范化字符串后,签名生成的完整流程就清晰了:

// 完整的 HMAC-SHA256 请求签名生成
const crypto = require('crypto');

function generateSignature(appSecret, method, path, query, headers, body) {
  // Step 1: 构建规范化请求字符串
  const canonicalRequest = buildCanonicalRequest(method, path, query, headers, body);

  // Step 2: 对规范化字符串计算 SHA256 哈希
  const hashedRequest = crypto
    .createHash('sha256')
    .update(canonicalRequest, 'utf8')
    .digest('hex');

  // Step 3: 使用 HMAC-SHA256 生成最终签名
  const signature = crypto
    .createHmac('sha256', appSecret)
    .update(hashedRequest, 'utf8')
    .digest('hex')
    .toLowerCase();

  return signature;
}

// 使用示例
const signature = generateSignature(
  'your-app-secret-key-2026',
  'POST',
  '/api/v1/orders',
  { page: '1', size: '20' },
  {
    'content-type': 'application/json',
    'x-timestamp': '1717382400',
    'x-nonce': 'a3f8b2c1d4e5'
  },
  JSON.stringify({ productId: 'SKU-001', quantity: 2 })
);

console.log('签名:', signature);
// 输出: 签名: 7a3b5c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b

🛡️ 二、防重放攻击:时间戳 + Nonce 机制

有了签名,请求就不会被篡改了。但攻击者可以原封不动地重放一个合法请求——比如重复提交订单、重复扣款。这就是重放攻击(Replay Attack),签名本身无法防御。

重放攻击的典型场景包括:支付回调被重放导致重复退款、短信验证码接口被重放导致轰炸攻击、内部微服务间调用被恶意员工捕获后重放执行越权操作。在分布式系统中,消息队列的消费者如果处理逻辑不是幂等的,一条消息被重复消费就可能造成数据不一致。因此,防重放是 API 安全体系中不可或缺的一环。

2.1 时间戳校验窗口

最基础的防重放手段是要求每个请求携带时间戳(Timestamp),服务端校验时间差是否在允许范围内:

// 时间戳校验中间件
function timestampValidator(req, res, next) {
  const timestamp = parseInt(req.headers['x-timestamp'], 10);
  const now = Math.floor(Date.now() / 1000);

  // 允许的最大时间偏差:5 分钟
  const MAX_TIME_DRIFT = 300;

  if (!timestamp || isNaN(timestamp)) {
    return res.status(401).json({
      code: 'INVALID_TIMESTAMP',
      message: '缺少或无效的时间戳'
    });
  }

  const drift = Math.abs(now - timestamp);
  if (drift > MAX_TIME_DRIFT) {
    return res.status(401).json({
      code: 'TIMESTAMP_EXPIRED',
      message: `请求已过期,时间偏差 ${drift} 秒,最大允许 ${MAX_TIME_DRIFT} 秒`
    });
  }

  next();
}

但仅靠时间戳有一个致命缺陷:在 5 分钟窗口内,同一个请求可以被无限重放。因此需要配合 Nonce 机制。

2.2 Nonce 去重的 Redis 实现

Nonce(Number used Once)是一个一次性随机值。服务端在接收到请求后,检查该 Nonce 是否已经使用过。如果已使用,直接拒绝。

// Nonce 去重验证器(Redis 实现)
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

async function nonceValidator(req, res, next) {
  const nonce = req.headers['x-nonce'];
  const appId = req.headers['x-app-id'];

  if (!nonce || nonce.length < 8) {
    return res.status(401).json({
      code: 'INVALID_NONCE',
      message: '缺少或无效的 Nonce'
    });
  }

  // Redis Key 格式:nonce:{appId}:{nonce}
  // 过期时间与时间戳窗口一致(5 分钟)
  const redisKey = `nonce:${appId}:${nonce}`;

  // SET NX(Set if Not eXists)是原子操作,天然适合做去重
  const acquired = await redis.set(redisKey, '1', 'EX', 300, 'NX');

  if (!acquired) {
    return res.status(401).json({
      code: 'NONCE_REUSED',
      message: 'Nonce 已使用,疑似重放攻击'
    });
  }

  next();
}

📌 **记住:**Nonce 的过期时间必须与时间戳窗口一致。如果时间戳窗口是 5 分钟,Nonce 也应在 Redis 中保留 5 分钟后自动过期。过期时间太短会导致重放窗口变大,太长则浪费内存。

2.3 完整的签名验证流程

将签名验证、时间戳校验和 Nonce 去重组合成一个完整的中间件:

// 完整的 API 签名验证中间件
const crypto = require('crypto');

function createSignatureValidator(options = {}) {
  const {
    getSecret,           // async (appId) => appSecret
    timeWindow = 300,    // 时间窗口(秒)
    signedHeaders = ['content-type', 'x-timestamp', 'x-nonce']
  } = options;

  return async function signatureValidator(req, res, next) {
    try {
      // 1. 提取请求头中的签名信息
      const appId = req.headers['x-app-id'];
      const timestamp = req.headers['x-timestamp'];
      const nonce = req.headers['x-nonce'];
      const clientSignature = req.headers['x-signature'];

      if (!appId || !timestamp || !nonce || !clientSignature) {
        return res.status(401).json({
          code: 'MISSING_AUTH_HEADERS',
          message: '缺少必要的认证请求头'
        });
      }

      // 2. 时间戳校验
      const now = Math.floor(Date.now() / 1000);
      if (Math.abs(now - parseInt(timestamp, 10)) > timeWindow) {
        return res.status(401).json({
          code: 'TIMESTAMP_EXPIRED',
          message: '请求已过期'
        });
      }

      // 3. Nonce 去重校验
      const nonceKey = `nonce:${appId}:${nonce}`;
      const acquired = await redis.set(nonceKey, '1', 'EX', timeWindow, 'NX');
      if (!acquired) {
        return res.status(401).json({
          code: 'NONCE_REUSED',
          message: 'Nonce 已使用'
        });
      }

      // 4. 获取应用密钥
      const appSecret = await getSecret(appId);
      if (!appSecret) {
        return res.status(401).json({
          code: 'UNKNOWN_APP',
          message: '未知的应用 ID'
        });
      }

      // 5. 重新计算签名并比对
      const body = req.rawBody || '';
      const canonicalRequest = buildCanonicalRequest(
        req.method, req.path, req.query, req.headers, body
      );

      const expectedSignature = crypto
        .createHmac('sha256', appSecret)
        .update(
          crypto.createHash('sha256').update(canonicalRequest).digest('hex')
        )
        .digest('hex')
        .toLowerCase();

      // 6. 使用 timingSafeEqual 防止时序攻击
      const sigBuffer = Buffer.from(clientSignature.toLowerCase(), 'hex');
      const expectedBuffer = Buffer.from(expectedSignature, 'hex');

      if (sigBuffer.length !== expectedBuffer.length ||
          !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
        return res.status(401).json({
          code: 'SIGNATURE_MISMATCH',
          message: '签名验证失败'
        });
      }

      // 7. 验证通过,注入 appId 到请求上下文
      req.appId = appId;
      next();

    } catch (error) {
      console.error('签名验证异常:', error);
      return res.status(500).json({
        code: 'AUTH_ERROR',
        message: '认证服务异常'
      });
    }
  };
}

⚠️ **警告:**第 6 步的 crypto.timingSafeEqual 至关重要。如果使用普通的 === 比较两个签名字符串,攻击者可以通过测量响应时间逐字节猜出正确签名(时序攻击)。这是很多开源项目都会忽略的安全细节。

🚀 三、多语言生产级实现

3.1 Express.js 完整集成

将签名验证集成到 Express 应用中,只需几行代码:

// Express 集成示例
const express = require('express');
const app = express();

// 重要:需要保存原始请求体用于签名验证
app.use(express.json({
  verify: (req, _res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

// 密钥存储(生产环境应从配置中心或数据库获取)
const APP_SECRETS = {
  'app-001': 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  'app-002': 'sk-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy',
};

// 挂载签名验证中间件
const verifySignature = createSignatureValidator({
  getSecret: async (appId) => APP_SECRETS[appId] || null,
  timeWindow: 300
});

// 需要签名验证的路由
app.post('/api/v1/orders', verifySignature, (req, res) => {
  res.json({ code: 0, message: '订单创建成功', appId: req.appId });
});

// 不需要签名验证的路由(如健康检查)
app.get('/health', (_req, res) => {
  res.json({ status: 'ok' });
});

app.listen(3000, () => console.log('API 服务运行在 :3000'));

3.2 Java Spring Boot 实现

对于 Java 开发者,可以用 Filter 或 Interceptor 实现同样的逻辑:

// ApiSignatureFilter.java
@Component
public class ApiSignatureFilter extends OncePerRequestFilter {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private AppSecretService appSecretService;

    private static final long TIME_WINDOW = 300; // 5 分钟

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        // 跳过不需要签名验证的路径
        String path = request.getRequestURI();
        if (path.startsWith("/health") || path.startsWith("/actuator")) {
            chain.doFilter(request, response);
            return;
        }

        String appId = request.getHeader("X-App-Id");
        String timestamp = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String clientSignature = request.getHeader("X-Signature");

        // 1. 参数完整性校验
        if (appId == null || timestamp == null || nonce == null || clientSignature == null) {
            sendError(response, 401, "MISSING_AUTH_HEADERS", "缺少认证请求头");
            return;
        }

        // 2. 时间戳校验
        long now = Instant.now().getEpochSecond();
        long requestTime = Long.parseLong(timestamp);
        if (Math.abs(now - requestTime) > TIME_WINDOW) {
            sendError(response, 401, "TIMESTAMP_EXPIRED", "请求已过期");
            return;
        }

        // 3. Nonce 去重(Redis SETNX)
        String nonceKey = "nonce:" + appId + ":" + nonce;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(nonceKey, "1", Duration.ofSeconds(TIME_WINDOW));
        if (!Boolean.TRUE.equals(acquired)) {
            sendError(response, 401, "NONCE_REUSED", "Nonce 已使用");
            return;
        }

        // 4. 获取密钥并验证签名
        String appSecret = appSecretService.getSecret(appId);
        if (appSecret == null) {
            sendError(response, 401, "UNKNOWN_APP", "未知应用");
            return;
        }

        // 5. 读取请求体(需要缓存,因为 InputStream 只能读一次)
        CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request);
        String body = new String(cachedRequest.getCachedBody(), StandardCharsets.UTF_8);

        String expectedSignature = SignatureUtils.hmacSha256(
            appSecret, cachedRequest.getMethod(), cachedRequest.getRequestURI(),
            cachedRequest.getParameterMap(), cachedRequest.getHeaderMap(), body
        );

        // 6. 使用 MessageDigest.isEqual 防止时序攻击
        if (!MessageDigest.isEqual(
            expectedSignature.getBytes(StandardCharsets.UTF_8),
            clientSignature.getBytes(StandardCharsets.UTF_8))) {
            sendError(response, 401, "SIGNATURE_MISMATCH", "签名验证失败");
            return;
        }

        chain.doFilter(cachedRequest, response);
    }
}

3.3 客户端签名 SDK

为了让调用方更方便地接入,建议封装一个客户端 SDK:

// 客户端签名 SDK
class ApiClient {
  constructor(appId, appSecret, baseURL) {
    this.appId = appId;
    this.appSecret = appSecret;
    this.baseURL = baseURL;
  }

  // 生成随机 Nonce
  _generateNonce(length = 32) {
    return crypto.randomBytes(length).toString('hex').slice(0, length);
  }

  // 发起签名请求
  async request(method, path, data = null) {
    const url = new URL(path, this.baseURL);
    const query = Object.fromEntries(url.searchParams);
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const nonce = this._generateNonce();
    const body = data ? JSON.stringify(data) : '';

    const headers = {
      'content-type': 'application/json',
      'x-app-id': this.appId,
      'x-timestamp': timestamp,
      'x-nonce': nonce,
    };

    // 计算签名
    const signature = generateSignature(
      this.appSecret, method, url.pathname, query, headers, body
    );
    headers['x-signature'] = signature;

    // 发起请求
    const response = await fetch(url.toString(), {
      method,
      headers,
      body: body || undefined,
    });

    return response.json();
  }
}

// 使用示例
const client = new ApiClient(
  'app-001',
  'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  'https://api.example.com'
);

const result = await client.request('POST', '/api/v1/orders', {
  productId: 'SKU-001',
  quantity: 2
});

⚠️ 四、常见坑点与避坑指南

在实际项目中落地 API 签名时,以下问题是最高频的踩坑点:

4.1 时序攻击(Timing Attack)

❌ **错误写法:**使用普通字符串比较

// 危险!易受时序攻击
if (clientSignature === expectedSignature) {
  next();
}

✅ **正确写法:**使用恒定时间比较

// 安全:恒定时间比较
const sigBuf = Buffer.from(clientSignature, 'hex');
const expBuf = Buffer.from(expectedSignature, 'hex');
if (sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf)) {
  next();
}

4.2 请求体丢失

Express/Koa 默认的 body parser 会消费掉 req.body 的 InputStream,导致签名验证时读不到原始请求体。

✅ **解决方案:**在 body parser 中保存 rawBody

// Express
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); }
}));

// Koa (koa-body)
app.use(koaBody({
  includeUnparsed: true,
  onError: (err) => { throw err; }
}));

4.3 代理层修改请求头

经过 Nginx、CDN 或 API 网关时,某些请求头可能被添加、删除或修改(如 X-Forwarded-ForVia)。如果这些头参与了签名计算,验证必然失败。

解决方案:只对应用层控制的、不会被代理修改的请求头进行签名。推荐参与签名的头:Content-Type、自定义业务头(如 X-TimestampX-Nonce)。避免对 HostUser-AgentCookie 等头签名。

4.4 Nonce 存储的内存问题

如果每个 Nonce 都用一个 Redis Key 存储,高并发下 Key 数量会爆炸式增长。

✅ **优化方案:**使用 Redis Hash 结构,按 appId 分桶存储:

// 使用 Hash 结构减少 Key 数量
async function checkNonceWithHash(appId, nonce) {
  const hashKey = `nonce_bucket:${appId}:${Math.floor(Date.now() / 60000)}`;
  const isNew = await redis.hsetnx(hashKey, nonce, '1');
  await redis.expire(hashKey, 3600); // 1 小时后自动清理整个桶
  return isNew === 1;
}

💡 五、最佳实践总结

在生产环境中部署 API 签名机制,需要牢记以下原则:

签名机制的设计需要在安全性和工程复杂度之间找到平衡点。过度设计(比如对所有 GET 请求也强制签名)会增加客户端接入成本,降低开发者体验;而防护不足(比如只验证签名不校验时间戳)则会留下安全漏洞。一个实用的原则是:根据接口的敏感程度分级处理——查询类接口可以只做签名校验,写入类接口必须同时验证签名、时间戳和 Nonce,涉及资金的操作还应该额外绑定会话状态和操作确认机制。

推荐做法:

  • 对所有写操作(POST/PUT/DELETE)强制签名验证,GET 请求可选
  • 密钥使用至少 32 字节的随机字符串,定期轮换
  • Nonce 过期时间与时间戳窗口保持一致
  • 使用 timingSafeEqual 防止时序攻击
  • 保留 rawBody 用于签名验证
  • 密钥从配置中心或 KMS 获取,不要硬编码在代码中

避免做法:

  • 不要对会被代理层修改的请求头签名
  • 不要在签名中包含整个请求体(大文件上传场景应该签文件哈希)
  • 不要使用 MD5 或 SHA1 作为签名算法(已不安全)
  • 不要将 Nonce 存储在内存中(多实例部署会失效)

关键结论:API 签名不是银弹,它是安全体系中的一层防护。完整的 API 安全方案还需要结合 HTTPS 传输加密、OAuth 2.0 身份认证、IP 白名单、请求频率限制等多层防御。签名解决的是请求完整性和防重放问题,不要指望它替代其他安全机制。

🔧 相关工具推荐

📚 相关文章