API 接口签名方案实战:防篡改+防重放+防泄漏,三合一设计
引言
去年我们开放了一组 API 给合作方调用。上线两周,安全团队做了一次渗透测试,结果三连暴击:
- 篡改:合作方调"创建订单"接口,金额参数被中间人从
amount=9900改成amount=1,9900 的货一块钱拿走。HTTPS?用的是自建代理抓包改的,HTTPS 只防链路嗅探,不防应用层改包 - 重放:攻击者抓到一条"发放优惠券"的成功请求,原封不动重放了 873 次,券池被薅干
- 泄漏:合作方把
secretKey明文写在前端 JS 里,被 F12 一秒扒走,拿着 key 想签什么签什么
三个问题,一个比一个疼。我们的修复方案叫 timestamp + nonce + sign 三合一签名:时间戳防重放窗口、随机数防重放、HMAC-SHA256 签名防篡改和防 key 泄漏。上线后渗透复测三项全部通过。
这篇文章把整套方案从需求拆解到代码实现完整讲一遍,覆盖:
- 三合一设计的底层逻辑:每个字段为什么必须有、HTTPS 为什么不够
- 客户端签名算法:参数排序 → 拼接 → HMAC-SHA256 → Base64
- 服务端校验全流程:时间窗口 → nonce 去重 → 重算签名比对
- 防 key 泄漏的两道闸:HMAC 替代 MD5 + 服务端密钥管理
- 完整 Spring Boot 代码 + Postman 自动签名脚本,可直接抄走
一、先想清楚:到底在防什么
1.1 三个威胁与三道锁
| 威胁 | 攻击方式 | 传统防御的漏洞 | 三合一的锁 |
|---|---|---|---|
| 篡改 | 中间人/代理抓包改参数 | HTTPS 只加密链路,不防应用层改包;对方拿到密文可解密改写(中间人代理) | sign 签名:参数改一个字节,签名就对不上 |
| 重放 | 抓到合法请求原封不动重发 | HTTPS 完全不防重放,请求合法就能重复执行 | timestamp + nonce:5 分钟过期 + 一次性使用 |
| 泄漏 | secretKey 被扒/被反编译 | MD5+key 方案一旦 key 泄漏可伪造任意签名 | HMAC-SHA256:即使 key 泄漏,无算法规范也难伪造;配合服务端密钥轮转 |
1.2 为什么 HTTPS 不够
这是最常被问的问题——"都上 HTTPS 了还需要签名?"需要。原因有三:
① HTTPS 保护的是链路,不是端点。客户端到服务端的链路加密了,但如果请求经过你的反向代理、API 网关、WAF,任何一个节点的运维都能看到明文。合作方调你的 API,你无法保证对方内网没有抓包代理
② HTTPS 不防重放。加密的请求被抓到后,攻击者不解密、不改包,直接把密文重放给服务端——服务端能正常解密、正常执行。HTTPS 对此无能为力
③ HTTPS 不证明身份授权。TLS 客户端证书可以做双向认证,但证书管理对第三方合作方来说太重了。签名方案用一对 appId + secretKey 轻量解决"你是谁、你有没有权限调这个接口"
1.3 三合一签名的请求结构
最终方案下,每个 API 请求携带五个签名相关字段:
POST /api/v1/order/create
Content-Type: application/json
X-App-Id: app_001 # ① 应用标识(服务端据此查 secretKey)
X-Timestamp: 1724280000000 # ② 毫秒级时间戳
X-Nonce: a1b2c3d4e5f6 # ③ 随机数(一次性)
X-Sign: 7kXR9p2Q...(Base64编码的签名值) # ④ HMAC-SHA256 签名
{"productId":"P1001","amount":9900,"userId":"U5001"} # ⑤ 业务参数
- ①②③④ 是签名体系字段,⑤ 是业务参数
- 签名覆盖的内容是 ②③④ + ⑤ 全部业务参数,① 是"查 key 的索引"不参与签名(下面解释为什么)
二、签名算法:客户端怎么算 sign
2.1 算法全流程(7 步)
┌──────────────────────────────────────────────────┐
原始请求 │ 1. 提取所有业务参数(query + body 的 JSON 字段) │
┌──────┐ │ 2. 参数名按 ASCII 升序排序 │
│ body │──→ │ 3. 按 key=value 用 & 拼接成 stringA │
│query │ │ 4. 末尾追加 timestamp & nonce │
└──────┘ │ 5. stringA = "amount=9900&nonce=xxx&productId=..." │
│ 6. sign = HMAC-SHA256(secretKey, stringA) │
│ 7. Base64(sign) → 放入 X-Sign 头 │
└──────────────────────────────────────────────────┘
逐步说明:
第 1 步:提取参数。把 query string 和 JSON body 里的所有字段拍平成一个 Map。嵌套 JSON 递归拍平(user.id → user.id=5001)。文件上传类接口签名 file 内容的 SHA256。
第 2 步:参数名 ASCII 升序排序。这一步看似多余,实则关键——客户端和服务端不同语言、不同 JSON 库的遍历顺序可能不同,排序保证双方拼接出的 stringA 逐字节一致。排序规则:按参数名 ASCII 码升序(amount < nonce < productId < timestamp < userId)。
第 3 步:拼接。key1=value1&key2=value2&...,value 做 URL encode(防止 &、= 符号歧义)。
第 4 步:追加时间戳和 nonce。这两个字段也参与签名,否则攻击者改了时间戳把过期请求"续命",签名依然能通过。
第 5 步:拼接结果。举例:
amount=9900&nonce=a1b2c3d4e5f6&productId=P1001×tamp=1724280000000&userId=U5001
第 6 步:HMAC-SHA256。用 secretKey 做密钥,对 stringA 做 HMAC-SHA256,输出 32 字节的二进制摘要。注意:不是 MD5(secretKey + stringA),是标准 HMAC,下文 2.2 解释为什么。
第 7 步:Base64 编码。把 32 字节二进制转成 Base64 字符串,放入 X-Sign 请求头。
2.2 为什么是 HMAC-SHA256 而不是 MD5/SHA256
这是签名方案里最容易被"图省事"做错的地方。三种做法的安全性对比:
| 方案 | 公式 | 致命问题 |
|---|---|---|
| MD5(key + data) | md5(secretKey + stringA) | ① MD5 已被破解,碰撞攻击可行;② 拼接式哈希存在长度扩展攻击(append 伪造数据) |
| SHA256(key + data) | sha256(secretKey + stringA) | 仍存在长度扩展攻击(SHA2 家族同理) |
| HMAC-SHA256(key, data) | hmac_sha256(secretKey, stringA) | HMAC 结构从数学上消除长度扩展攻击;SHA256 未被破解 |
长度扩展攻击原理一句话:知道 SHA256(key + msg) 的结果和 msg,但不知道 key,也能算出 SHA256(key + msg + padding + append) 的合法签名。MD5/SHA-256 这类 Merkle-Damgård 结构哈希都中招。HMAC 通过双次哈希 + pad 结构从构造上堵住了这个口子。
所以:凡是签名场景,一律用 HMAC,不要用拼接式哈希。这是支付公司、云厂商(阿里云、AWS)签名规范全部采用 HMAC 的原因。
2.3 客户端签名代码(Java 版)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
public class ApiSignUtil {
/**
* 生成签名
*
* @param params 业务参数(query + body 拍平后的 Map)
* @param timestamp 毫秒时间戳
* @param nonce 随机串
* @param secretKey 应用密钥
* @return Base64 编码的签名值
*/
public static String sign(Map<String, String> params, long timestamp,
String nonce, String secretKey) throws Exception {
// 1. 参数名 ASCII 升序(TreeMap 自然有序)
TreeMap<String, String> sorted = new TreeMap<>(params);
// 2. 追加 timestamp 和 nonce(它们也参与签名)
sorted.put("timestamp", String.valueOf(timestamp));
sorted.put("nonce", nonce);
// 3. 拼接 key=value&key=value
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : sorted.entrySet()) {
if (sb.length() > 0) sb.append('&');
sb.append(e.getKey()).append('=')
.append(java.net.URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
}
String stringA = sb.toString();
// 4. HMAC-SHA256
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(stringA.getBytes(StandardCharsets.UTF_8));
// 5. Base64 编码
return Base64.getEncoder().encodeToString(digest);
}
/** 生成 nonce(16 位十六进制随机串) */
public static String generateNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
}
调用方式:
Map<String, String> params = Map.of(
"productId", "P1001",
"amount", "9900",
"userId", "U5001");
long timestamp = System.currentTimeMillis();
String nonce = ApiSignUtil.generateNonce();
String sign = ApiSignUtil.sign(params, timestamp, nonce, "your-secret-key-here");
// 组装请求头
HttpHeaders headers = new HttpHeaders();
headers.set("X-App-Id", "app_001");
headers.set("X-Timestamp", String.valueOf(timestamp));
headers.set("X-Nonce", nonce);
headers.set("X-Sign", sign);
三、服务端校验:四道关卡
3.1 校验流程总览
服务端收到请求后,按顺序过四道关卡,任一关失败即拒绝(返回对应错误码),全部通过才放行执行业务逻辑:
请求到达
│
├─ 关卡1:必填头检查
│ X-App-Id / X-Timestamp / X-Nonce / X-Sign 是否齐全?
│ └─ 缺任一个 → 401 "缺少签名参数"
│
├─ 关卡2:时间窗口校验(防重放第一道)
│ |serverTime - clientTime| ≤ 5 分钟?
│ └─ 超出 → 401 "请求已过期"
│
├─ 关卡3:nonce 去重(防重放第二道)
│ Redis SETNX(nonce) 成功?
│ └─ 已存在 → 401 "重复请求"
│
├─ 关卡4:签名比对(防篡改)
│ 重算 sign == X-Sign?
│ └─ 不等 → 401 "签名校验失败"
│
└─ 全部通过 → 执行业务逻辑
四道关卡的顺序不是随便排的,有一个重要设计原则:先做最便宜的检查,最贵的放最后。时间戳比较是一次减法(纳秒级),nonce 查 Redis 是一次网络往返(毫秒级),签名重算是 HMAC 计算(微秒级但比减法贵)。把签名比对放最后,能让被限流/重放拦截的攻击请求不浪费 HMAC 计算。
但这里有个取舍:nonce 检查和签名检查的顺序。如果先查 nonce 再验签,一个伪造签名的攻击请求会白占一次 Redis 往返。反过来先验签再查 nonce,合法请求的重放才会走到 Redis。我们选择"时间戳 → 签名 → nonce"的顺序:签名验过了再花 Redis 往返查重放,攻击流量在签名关就被挡住。下面的代码按这个顺序实现。
3.2 完整校验代码(Spring Boot 拦截器)
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* API 签名校验拦截器:四道关卡串联
*
* 配置方式(WebMvcConfigurer):
* registry.addInterceptor(new ApiSignInterceptor(redis, appSecrets))
* .addPathPatterns("/api/v1/**");
*/
@Component
public class ApiSignInterceptor implements HandlerInterceptor {
/** 时间窗口:5 分钟(毫秒) */
private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
/** nonce 在 Redis 的过期时间 = 窗口 + 1 分钟缓冲 */
private static final long NONCE_TTL_MINUTES = 6;
private final StringRedisTemplate redis;
/** appId → secretKey 的映射(生产从 DB/配置中心加载) */
private final Map<String, String> appSecrets;
public ApiSignInterceptor(StringRedisTemplate redis, Map<String, String> appSecrets) {
this.redis = redis;
this.appSecrets = appSecrets;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// ── 关卡 1:必填头检查 ──
String appId = request.getHeader("X-App-Id");
String timestampStr = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String sign = request.getHeader("X-Sign");
if (isBlank(appId) || isBlank(timestampStr) || isBlank(nonce) || isBlank(sign)) {
return reject(response, 401, "缺少签名参数");
}
// ── 关卡 2:时间窗口 ──
long clientTime;
try {
clientTime = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
return reject(response, 401, "时间戳格式错误");
}
long diff = Math.abs(System.currentTimeMillis() - clientTime);
if (diff > TIMESTAMP_WINDOW_MS) {
return reject(response, 401, "请求已过期,时间差 " + diff + "ms");
}
// 查 secretKey
String secretKey = appSecrets.get(appId);
if (secretKey == null) {
return reject(response, 401, "无效的 App-Id");
}
// ── 关卡 3:签名比对(先验签再查 nonce,挡住攻击流量) ──
Map<String, String> params = extractParams(request); // query + body 拍平
String expectedSign = computeSign(params, clientTime, nonce, secretKey);
if (!constantTimeEquals(expectedSign, sign)) {
return reject(response, 401, "签名校验失败");
}
// ── 关卡 4:nonce 去重(防重放) ──
String nonceKey = "api:nonce:" + appId + ":" + nonce;
Boolean firstSeen = redis.opsForValue().setIfAbsent(nonceKey, "1",
NONCE_TTL_MINUTES, TimeUnit.MINUTES);
if (firstSeen == null || !firstSeen) {
return reject(response, 401, "重复请求(nonce 已使用)");
}
return true; // 放行
}
// ────────────────── 工具方法 ──────────────────
/** 提取参数:query string + JSON body 拍平成 Map */
private Map<String, String> extractParams(HttpServletRequest request) throws Exception {
TreeMap<String, String> params = new TreeMap<>();
// query string
request.getParameterMap().forEach((k, v) ->
params.put(k, v.length > 0 ? v[0] : ""));
// JSON body(只处理 application/json)
String contentType = request.getContentType();
if (contentType != null && contentType.contains("application/json")) {
// 需要 ContentCachingRequestWrapper 或 filter 缓存 body(见 3.3 说明)
String body = (String) request.getAttribute("CACHED_BODY");
if (body != null && !body.isBlank()) {
flattenJson(body, params);
}
}
return params;
}
/** 递归拍平 JSON(简单实现,生产用 Jackson) */
private void flattenJson(String json, TreeMap<String, String> out) {
// 省略 Jackson ObjectMapper 解析 + 递归拍平
// 嵌套对象:{"user":{"id":1}} → user.id=1
// 数组:{"tags":["a","b"]} → tags[0]=a&tags[1]=b
}
/** 重算签名(与客户端算法完全一致) */
private String computeSign(Map<String, String> params, long timestamp,
String nonce, String secretKey) throws Exception {
TreeMap<String, String> sorted = new TreeMap<>(params);
sorted.put("timestamp", String.valueOf(timestamp));
sorted.put("nonce", nonce);
StringBuilder sb = new StringBuilder();
for (var e : sorted.entrySet()) {
if (sb.length() > 0) sb.append('&');
sb.append(e.getKey()).append('=')
.append(java.net.URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(digest);
}
/** 常量时间比较:防止计时攻击(见 3.4 说明) */
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) return false;
if (a.length() != b.length()) return false;
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
private boolean isBlank(String s) { return s == null || s.isBlank(); }
private boolean reject(HttpServletResponse resp, int status, String msg) throws Exception {
resp.setStatus(status);
resp.setContentType("application/json; charset=utf-8");
resp.getWriter().write("{\"code\":" + status + ",\"message\":\"" + msg + "\"}");
return false;
}
}
3.3 一个必须处理的细节:body 读取问题
HttpServletRequest 的 body 是流式的,只能读一次。拦截器读了 body 做签名,Controller 的 @RequestBody 再读就是空的。解法是用 Servlet Filter 缓存 body:
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingRequestWrapper;
import java.io.IOException;
/**
* 缓存请求 body,使拦截器和 Controller 都能读取
*/
@Component
public class BodyCachingFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
if (req instanceof HttpServletRequest httpReq
&& httpReq.getContentType() != null
&& httpReq.getContentType().contains("application/json")) {
ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(httpReq);
// 预读 body 到缓存
wrapped.getInputStream().readAllBytes();
wrapped.setAttribute("CACHED_BODY",
new String(wrapped.getContentAsByteArray(),
java.nio.charset.StandardCharsets.UTF_8));
chain.doFilter(wrapped, resp);
} else {
chain.doFilter(req, resp);
}
}
}
ContentCachingRequestWrapper 是 Spring 自带的包装类,它缓存 body 字节,getContentAsByteArray() 可重复读。这是签名校验类方案绕不过去的一个工程细节。
3.4 常量时间比较:防计时攻击
代码里的 constantTimeEquals 不是多此一举。如果用 expectedSign.equals(sign),String.equals 在第一个不匹配字符处就返回 false——攻击者可以逐字节试探,每次比较耗时微秒级差异能被统计出来,从而猜出签名的前缀。常量时间比较无论结果是否相等,都遍历完整字符串,消除计时侧信道。
虽然 HMAC-SHA256 签名有 256 位熵,计时攻击实际可行性极低,但安全代码的规范做法就是用常量时间比较——这是"正确"和"严谨"的区别。
四、防 key 泄漏:HMAC 之外还有两道闸
4.1 key 泄漏的后果与现状
secretKey 是整个签名体系的根。一旦泄漏,攻击者可以伪造任意合法签名,timestamp+nonce 全部失效。现实里 key 泄漏的常见途径:
| 途径 | 典型场景 |
|---|---|
| 前端硬编码 | 合作方把 key 写在前端 JS/App 里,F12/反编译一秒扒走 |
| 代码仓库 | secretKey 硬编码在代码里提交到 Git,离职员工带走 |
| 日志 | 签名调试日志打印了 stringA(含 key 被拼接的痕迹)或直接打印 key |
| 配置文件 | application.yml 里的 key 被运维截图发群 |
4.2 第一道闸:服务端密钥管理
/**
* 密钥管理:生产环境的标准做法
*/
public class AppSecretManager {
private final Map<String, AppSecret> cache; // appId → 密钥信息
record AppSecret(String currentKey, String previousKey, long rotatedAt) {}
/**
* 密钥轮转:旧 key 保留一个轮转周期(比如 7 天),期间两种 key 都能验签
* 配合配置中心推送,不停机换 key
*/
public String getSecret(String appId, String keyVersion) {
AppSecret secret = cache.get(appId);
if (secret == null) return null;
return "v2".equals(keyVersion) ? secret.currentKey() : secret.previousKey();
}
/**
* 最小权限:每个 appId 只能调授权的接口
* 即使 key 泄漏,攻击者也只能打授权列表里的接口
*/
public boolean canAccess(String appId, String apiPath) {
// 查 appId → 接口白名单
return true;
}
}
三条原则:
- key 只存服务端。合作方调 API 时 key 留在后端服务里,前端只拿 token。绝不能 key 进前端
- key 可轮转。生产环境定期换 key,旧 key 保留一个过渡期。泄漏后可以快速轮转止损
- 最小授权。每个 appId 绑定可调接口列表,泄漏了也只能打白名单内的接口
4.3 第二道闸:HMAC 的数学护城河
回顾 2.2:HMAC-SHA256 的结构使长度扩展攻击失效。即使攻击者拿到了一组 (stringA, sign) 样本,没有 secretKey 也无法伪造新的签名——HMAC 的安全性基于 HMAC 构造本身,而非 key 的保密性单独支撑。换句话说:HMAC 给 key 泄漏加了一层"即使 key 泄漏了,没算法规范也伪造不了"的护城河。当然这不是放纵 key 泄漏的理由,而是纵深防御的一层。
五、Postman 自动签名脚本
手动拼签名调试太痛苦。Postman 的 Pre-request Script 可以自动算签名、填请求头,合作方接入时发一个 collection 文件即可。
5.1 脚本完整代码
在 Postman 的 Collection → Pre-request Script 里粘贴:
// ===== API 签名自动生成(Postman Pre-request Script)=====
const APP_ID = 'app_001';
const SECRET_KEY = 'your-secret-key-here'; // 仅测试环境用,生产勿入库
// 1. 获取时间戳和 nonce
const timestamp = Date.now().toString();
const nonce = CryptoJS.lib.WordArray.random(8).toString(); // 16 位十六进制
// 2. 收集参数:query + body
let params = {};
// query string 参数
const url = new URL(pm.request.url.toString());
url.searchParams.forEach((v, k) => { params[k] = v; });
// JSON body 参数
if (pm.request.body && pm.request.body.raw) {
try {
const body = JSON.parse(pm.request.body.raw);
flattenObject(body, '', params);
} catch (e) { /* 非 JSON 跳过 */ }
}
// 3. 追加 timestamp 和 nonce
params.timestamp = timestamp;
params.nonce = nonce;
// 4. 参数名 ASCII 升序 → 拼接 key=value
const sortedKeys = Object.keys(params).sort();
const stringA = sortedKeys
.map(k => `${k}=${encodeURIComponent(params[k])}`)
.join('&');
// 5. HMAC-SHA256 + Base64
const sign = CryptoJS.HmacSHA256(stringA, SECRET_KEY)
.toString(CryptoJS.enc.Base64);
// 6. 设置请求头
pm.request.headers.upsert({ key: 'X-App-Id', value: APP_ID });
pm.request.headers.upsert({ key: 'X-Timestamp', value: timestamp });
pm.request.headers.upsert({ key: 'X-Nonce', value: nonce });
pm.request.headers.upsert({ key: 'X-Sign', value: sign });
// 调试用:Console 打印签名过程
console.log('stringA:', stringA);
console.log('sign:', sign);
// 递归拍平嵌套 JSON
function flattenObject(obj, prefix, out) {
for (const key in obj) {
const value = obj[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flattenObject(value, fullKey, out);
} else if (Array.isArray(value)) {
value.forEach((v, i) => { out[`${fullKey}[${i}]`] = String(v); });
} else {
out[fullKey] = String(value);
}
}
}
5.2 脚本说明
| 步骤 | 作用 | 对应服务端关卡 |
|---|---|---|
| 收集 query + body 参数 | 签名覆盖全部业务参数 | 关卡 3 签名比对 |
| 参数名排序 | 客户端服务端拼接一致 | 关卡 3 |
| 追加 timestamp + nonce | 时间戳和 nonce 也参与签名 | 关卡 2 + 3 |
| HMAC-SHA256 + Base64 | 与服务端算法完全一致 | 关卡 3 |
| 设置请求头 | 自动填四个签名头 | 关卡 1 |
5.3 测试验证
配置好后,每次发请求 Postman 自动签名。验证流程:
1. 正常请求 → 200 ✓
2. 改一个 body 参数 → 401 签名校验失败 ✓(篡改被拦截)
3. 等 6 分钟再发同样的请求 → 401 请求已过期 ✓(时间窗口)
4. 立刻重放同一条请求 → 401 重复请求 ✓(nonce 去重)
5. 删掉 X-Sign 头 → 401 缺少签名参数 ✓(必填检查)
六、常见问题
6.1 时间窗口 5 分钟会不会太短?客户端时钟偏差怎么办?
5 分钟是业界通用值(微信支付 5 分钟、阿里云 15 分钟)。覆盖了正常网络延迟和客户端时钟偏差。生产环境要求合作方做 NTP 时钟同步,这是 API 对接的基本要求。如果合作方确实在时钟不严的环境,可以放大到 15 分钟,但 nonce TTL 要同步放大到 16 分钟——窗口越大,nonce 在 Redis 的驻留时间越长,内存开销越大。
6.1 nonce 为什么不直接用 timestamp?timestamp + nonce 重复了
timestamp 粒度是毫秒,高并发下同一毫秒多个请求 timestamp 相同——如果 nonce 也用 timestamp,同一毫秒的请求 nonce 相同,会被误判为重放。nonce 必须是每次请求唯一的随机串(UUID 或随机十六进制),与 timestamp 互补:timestamp 管"过期",nonce 管"一次性"。
6.2 nonce 存 Redis 内存爆炸吗?
nonce 的 TTL = 时间窗口 + 缓冲 = 6 分钟。6 分钟后 Redis 自动过期。算账:1000 QPS × 6 分钟 × 36 字节(nonce key 平均长度)≈ 13MB。完全无压力。如果 QPS 上万,可以给 nonce key 前缀加分桶(按分钟分),方便批量清理。
6.3 GET 请求的参数和 POST body 都要签名吗?
都要。签名覆盖所有业务参数,不管来自 query 还是 body。GET 请求改 query 参数一样能篡改(?amount=1),所以 query 参数必须进签名。文件上传接口特殊处理:文件内容做 SHA256 摘要参与签名,文件流本身不算入 stringA。
6.4 签名方案和 HTTPS 矛盾吗?要不要二选一?
不矛盾,两者互补必须同时用。HTTPS 保护链路传输层,签名保护应用层语义。实际部署里,签名方案跑在 HTTPS 之上——HTTPS 防止链路被嗅探,签名防止内容被篡改/重放。这叫纵深防御(Defense in Depth)。
6.5 密钥泄漏后怎么办?
三步止损:
- 立即轮转 key:配置中心推新 key,旧 key 标记为"仅验签不接受"(给在途请求留缓冲)
- 审查调用日志:按 appId 查异常调用(时间集中、高频、陌生 IP),标记可疑请求
- 排查泄漏源:代码仓库、日志、配置文件、前端代码,找到并清除 key 硬编码
根本预防:key 只存服务端配置中心(Nacos/Apollo),代码里零硬编码,CI/CD 做密钥扫描。
七、总结
三合一速查卡
┌─────────────┬──────────────────────────────────────────────┐
│ 防篡改 │ sign = Base64(HMAC-SHA256(secretKey, stringA))│
│ │ stringA = 排序参数 + timestamp + nonce 拼接 │
├─────────────┼──────────────────────────────────────────────┤
│ 防重放 │ timestamp:5 分钟窗口(绝对过期) │
│ │ nonce:Redis SETNX 一次性去重(TTL = 窗口+1m) │
├─────────────┼──────────────────────────────────────────────┤
│ 防泄漏 │ HMAC-SHA256(抗长度扩展攻击) │
│ │ 服务端密钥管理(轮转 + 最小授权 + 零硬编码) │
├─────────────┼──────────────────────────────────────────────┤
│ 服务端四关卡 │ 必填头 → 时间窗口 → 签名比对 → nonce 去重 │
│ │ 顺序原则:便宜先做,贵的放后 │
└─────────────┴──────────────────────────────────────────────┘
签名算法步骤
1. 提取所有业务参数(query + body 拍平)
2. 参数名 ASCII 升序排序
3. key=value&key=value 拼接
4. 追加 timestamp & nonce
5. HMAC-SHA256(secretKey, stringA)
6. Base64 编码 → X-Sign 头
关键数据
- 时间窗口:5 分钟(业界通用,微信支付同款)
- nonce TTL:6 分钟(窗口 + 1 分钟缓冲)
- HMAC-SHA256 输出:32 字节 → Base64 44 字符
- 签名计算耗时:< 0.1ms
- Redis SETNX 耗时:< 0.5ms
- 整体校验增加的 RT:P99 < 1ms
一句话
timestamp 管过期,nonce 管一次性,sign 管完整性——三个字段各司其职,HMAC 替代 MD5 消灭长度扩展攻击,HTTPS 之上再套签名做纵深防御。签名方案不是"加个 sign 头"这么简单,但也没复杂到造轮子。
给团队的建议
| 场景 | 建议 |
|---|---|
| 开放 API 给第三方 | 必须上三合一签名,HTTPS + 签名缺一不可 |
| 内部微服务互调 | mTLS 或服务网格(Istio)更合适,签名方案太重 |
| 移动端 API | key 留后端换 token,前端拿 token 调,绝不让 key 进 App |
| 已有 MD5 签名方案 | 升级到 HMAC-SHA256,消除长度扩展攻击 |
| key 已泄漏 | 立即轮转 + 审查日志 + 排查硬编码 |
互动话题:你们对外开放 API 用的什么鉴权方案?有没有被薅过券、被改过金额?签名方案落地时最头疼的是参数排序还是 key 管理?评论区聊聊,点赞最高的送《白帽子讲 Web 安全》一本。
参考资料
- RFC 2104:HMAC 规范
- RFC 6151:MD5 安全性更新
- 长度扩展攻击原理
- 微信支付 API 签名规范
- 阿里云 API 签名规范
- AWS Signature Version 4 签名流程
- Spring ContentCachingRequestWrapper 文档
标题:API 接口签名方案实战:防篡改+防重放+防泄漏,三合一设计
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/31/1787988424652.html
公众号:服务端技术精选
- 引言
- 一、先想清楚:到底在防什么
- 1.1 三个威胁与三道锁
- 1.2 为什么 HTTPS 不够
- 1.3 三合一签名的请求结构
- 二、签名算法:客户端怎么算 sign
- 2.1 算法全流程(7 步)
- 2.2 为什么是 HMAC-SHA256 而不是 MD5/SHA256
- 2.3 客户端签名代码(Java 版)
- 三、服务端校验:四道关卡
- 3.1 校验流程总览
- 3.2 完整校验代码(Spring Boot 拦截器)
- 3.3 一个必须处理的细节:body 读取问题
- 3.4 常量时间比较:防计时攻击
- 四、防 key 泄漏:HMAC 之外还有两道闸
- 4.1 key 泄漏的后果与现状
- 4.2 第一道闸:服务端密钥管理
- 4.3 第二道闸:HMAC 的数学护城河
- 五、Postman 自动签名脚本
- 5.1 脚本完整代码
- 5.2 脚本说明
- 5.3 测试验证
- 六、常见问题
- 6.1 时间窗口 5 分钟会不会太短?客户端时钟偏差怎么办?
- 6.1 nonce 为什么不直接用 timestamp?timestamp + nonce 重复了
- 6.2 nonce 存 Redis 内存爆炸吗?
- 6.3 GET 请求的参数和 POST body 都要签名吗?
- 6.4 签名方案和 HTTPS 矛盾吗?要不要二选一?
- 6.5 密钥泄漏后怎么办?
- 七、总结
- 三合一速查卡
- 签名算法步骤
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论