API 接口签名方案实战:防篡改+防重放+防泄漏,三合一设计

引言

去年我们开放了一组 API 给合作方调用。上线两周,安全团队做了一次渗透测试,结果三连暴击:

  1. 篡改:合作方调"创建订单"接口,金额参数被中间人从 amount=9900 改成 amount=1,9900 的货一块钱拿走。HTTPS?用的是自建代理抓包改的,HTTPS 只防链路嗅探,不防应用层改包
  2. 重放:攻击者抓到一条"发放优惠券"的成功请求,原封不动重放了 873 次,券池被薅干
  3. 泄漏:合作方把 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.iduser.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&timestamp=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;
    }
}

三条原则:

  1. key 只存服务端。合作方调 API 时 key 留在后端服务里,前端只拿 token。绝不能 key 进前端
  2. key 可轮转。生产环境定期换 key,旧 key 保留一个过渡期。泄漏后可以快速轮转止损
  3. 最小授权。每个 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 密钥泄漏后怎么办?

三步止损:

  1. 立即轮转 key:配置中心推新 key,旧 key 标记为"仅验签不接受"(给在途请求留缓冲)
  2. 审查调用日志:按 appId 查异常调用(时间集中、高频、陌生 IP),标记可疑请求
  3. 排查泄漏源:代码仓库、日志、配置文件、前端代码,找到并清除 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)更合适,签名方案太重
移动端 APIkey 留后端换 token,前端拿 token 调,绝不让 key 进 App
已有 MD5 签名方案升级到 HMAC-SHA256,消除长度扩展攻击
key 已泄漏立即轮转 + 审查日志 + 排查硬编码

互动话题:你们对外开放 API 用的什么鉴权方案?有没有被薅过券、被改过金额?签名方案落地时最头疼的是参数排序还是 key 管理?评论区聊聊,点赞最高的送《白帽子讲 Web 安全》一本。


参考资料


标题:API 接口签名方案实战:防篡改+防重放+防泄漏,三合一设计
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/31/1787988424652.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消