根据 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-For、Via)。如果这些头参与了签名计算,验证必然失败。
✅ 解决方案:只对应用层控制的、不会被代理修改的请求头进行签名。推荐参与签名的头:Content-Type、自定义业务头(如 X-Timestamp、X-Nonce)。避免对 Host、User-Agent、Cookie 等头签名。
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 白名单、请求频率限制等多层防御。签名解决的是请求完整性和防重放问题,不要指望它替代其他安全机制。
🔧 相关工具推荐
- jsjson.com 在线 HMAC 生成器 — 在线验证 HMAC-SHA256 签名结果
- jsjson.com 时间戳转换工具 — 调试时间戳相关问题
- jsjson.com JSON 格式化工具 — 格式化 API 请求和响应
- Postman — 支持 Pre-request Script 自动生成签名
- AWS Signature V4 — 工业级 API 签名标准,可作为设计参考