API 签名防重放机制:基于 HMAC-SHA256 的设计与实现

调用第三方 API 时,怎么证明"这个请求确实是我发的"、"内容没被篡改"、"不能被别人拿去重放"?API Key 只能回答第一个问题。这篇文章从实际场景出发,一步步拆解 HMAC 签名方案。


从一个真实的对接场景说起

前阵子对接了一个公司其它部门平台的 API。对方给了我们两个东西:一个 appKey,一个 appSecret

  • appKey:应用标识,相当于"用户名",放在请求里让服务端知道你是谁。
  • appSecret :密钥,相当于"密码",用来算签名,只在本地使用,永远不传输

最开始的实现很简单------只用了 appKey,把它放到 HTTP Header 里就行了:

复制代码
GET /api/orders HTTP/1.1
Host: api.example.com
X-App-Key: my-app-key-12345

对方服务端收到请求,查一下这个 appKey 对应的权限,没问题就返回数据。

跑了一段时间,测试环境一切正常。但安全评审的时候被挑战了几个问题:

  1. appKey 是明文传输的------如果有人抓到这个请求,他可以直接拿 appKey 去调 API,我们完全拦不住。
  2. 请求体可以被篡改------比如我们发的是"查订单 A",中间人改成"查订单 B",服务端根本发现不了。
  3. 请求可以被重放------攻击者录下我们的一个请求,反复发送,每次都有效。

API Key 解决了"你是谁"的问题,但"内容有没有被改"和"这个请求是不是新鲜的"它完全不管。

所以得想个办法,一次性把这三个问题都解决掉。不过在动手之前,先把要防的东西列清楚,后面才好对症下药。


先搞清楚:我们到底要防什么

在设计方案之前,先明确三个安全目标:

目标 含义 API Key 能做到吗
真实性(Authenticity) 请求确实来自合法调用方 ✅ 有 appKey 就行
完整性(Integrity) 请求内容在传输过程中没有被篡改 ❌ 做不到
新鲜性(Freshness) 请求是刚发的,不是录下来重放的 ❌ 做不到

API Key 只能证明"你是谁",但不能证明"你发了什么"和"你什么时候发的"。

那怎么一次性解决这三个问题?答案是签名

说到签名,很多人脑子里第一个冒出来的就是"哈希"。思路没错,但光靠哈希还不够。我们先看看为什么。


从 Hash 说起:为什么不能直接用 SHA-256?

先简单介绍一下 SHA-256。它是 SHA-2 家族里的一个哈希算法,能把任意长度的输入压缩成一个 256 位(64 个十六进制字符)的固定长度输出,而且有两个关键特性:不可逆 (从输出推不回输入)和雪崩效应(输入改一个 bit,输出完全不同)。所以它天然适合做"指纹"------用来验证数据有没有被改过。

提到签名,很多人第一反应是"哈希"。比如把请求体做一次 SHA-256,附在请求后面:

复制代码
// 待发送的请求体
body = {"orderId": "12345", "amount": 99.00}
// 对 body 做一次 SHA-256 哈希,作为"签名"
signature = SHA256(body)
// → a3f2b8c9...(64 位十六进制)

服务端收到后,对 body 也做一次 SHA-256,对比签名。如果不一致,说明被篡改了。

这看起来解决了"完整性",但有个致命问题:攻击者也可以算 SHA-256

如果攻击者把 body 改成 {"orderId": "12345", "amount": 0.01},然后自己算一次 SHA-256,附上新的签名发过去------服务端验签通过,篡改成功。

问题出在哪?SHA-256 是一个无密钥 的哈希算法,任何人都能算。要防止篡改,必须让签名的计算依赖一个只有双方知道的密钥

这就是 HMAC 要解决的事。


HMAC:带密钥的哈希

HMAC(Hash-based Message Authentication Code)的核心思想很简单:

在哈希计算过程中混入一个密钥,这样只有持有密钥的人才能生成和验证签名。

HMAC-SHA256 的计算公式:

复制代码
// ⊕ 是异或(XOR)运算,ipad 和 opad 是两个固定的填充常量
// 外层哈希套内层哈希,密钥参与两次,保证安全性
HMAC-SHA256(key, message) = SHA256(key ⊕ opad || SHA256(key ⊕ ipad || message))

看起来很复杂,但你不需要记这个公式。实际使用时,Java 标准库已经封装好了:

java 复制代码
// 用密钥字节构造 SecretKeySpec,指定算法为 HmacSHA256
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256");
// 获取 HMAC 计算器实例
Mac mac = Mac.getInstance("HmacSHA256");
// 用密钥初始化
mac.init(keySpec);
// 传入待签名内容,计算签名
byte[] result = mac.doFinal(message.getBytes(UTF_8));

几行核心代码:初始化 Mac、算签名。就这么简单。

HMAC 保证了:没有密钥的人,既不能生成有效签名,也不能验证签名是否正确。

工具有了,接下来就是怎么用的问题------签名串里到底该放哪些东西?


签名内容的设计:放什么进去?

有了 HMAC,接下来的问题是:签名到底签什么?

最朴素的想法是只签请求体(body)。但这不够------攻击者可以不改 body,而是把整个请求换个时间再发一次(重放),或者换个接口路径再发(路由篡改)。

所以签名内容应该包含所有需要保护的信息。一个典型的签名串长这样:

复制代码
appKey=my-app-key&timestamp=1717500000000&nonce=a1b2c3d4e5f6&body={"orderId":"12345"}

四个部分,每个都有明确的安全意义:

appKey:标识调用方

放在签名串里,确保签名和调用方绑定。攻击者不能拿 A 的签名去冒充 B。

注意:签名串里放的是 appKey(公开标识),不是 appSecret(密钥)。appSecret 的角色是作为 HMAC 计算的密钥参与签名生成,但它本身不会出现在签名串里,也不会出现在 HTTP Header 里。它只在客户端本地和服务端本地各存一份。

timestamp:时间戳

当前时间的毫秒数。它的作用是给请求加一个"保质期"------服务端收到请求后,检查时间戳是否在可接受的窗口内(比如 5 分钟)。超过窗口的请求直接拒绝。

这解决了一部分"新鲜性"问题,但还不够。为什么?因为 5 分钟窗口内,同一个请求还是可以被重放。

nonce:随机数

一个一次性的随机值。服务端维护一个"已见过的 nonce 集合",同一个 nonce 只能用一次。

timestamp + nonce 组合起来:timestamp 限制了时间窗口,nonce 保证窗口内不会重放。两者缺一不可:

  • 只有 timestamp:5 分钟内可以重放
  • 只有 nonce:nonce 永远不过期,内存会爆

body:请求体

需要防篡改的核心数据。注意是完整的请求体,不是某个字段。

理论讲完了,下面用一个完整的例子把整个流程串起来。


完整流程:一步步走一遍

假设我们要调用一个"创建订单"的 API。

客户端(发送方)

复制代码
1. 生成 nonce:16 字节密码学安全随机数 → 32 位十六进制字符串
   // 每个请求一个,绝不重复
   nonce = "a1b2c3d4e5f67890a1b2c3d4e5f67890"

2. 获取当前时间戳(毫秒级)
   // 服务端会检查这个值是否在 5 分钟窗口内
   timestamp = "1717500000000"

3. 准备请求体(后续签名和实际发送要用同一个字符串)
   body = '{"orderId":"12345","amount":99.00}'

4. 拼接签名串(固定顺序,不能乱)
   // 四个 key-value 用 & 连接,顺序必须和服务端一致
   content = "appKey=my-app-key&timestamp=1717500000000&nonce=a1b2c3d4...&body={...}"

5. 用 appSecret 计算 HMAC-SHA256 签名
   // appSecret 只在这一步参与计算,不放到 Header 里
   signature = HMAC-SHA256(appSecret, content)
   → "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"

6. 把四个值放到 HTTP Header 里发出去
   // X-App-Key、X-Timestamp、X-Nonce、X-Sign

服务端(接收方)

复制代码
1. 从 Header 中取出 appKey、timestamp、nonce、signature

2. 检查时间戳是否在 5 分钟内
   // |当前时间 - timestamp| > 5分钟 → 说明请求过期,直接拒绝
   如果过期 → 拒绝

3. 检查 nonce 是否已经用过(防重放的关键)
   // Redis 中存在这个 nonce → 说明是重复请求
   如果重复 → 拒绝
   // 不存在则存入 Redis,过期时间和时间窗口一致(5 分钟)
   否则存起来 → 继续

4. 用 appKey 查到对应的 appSecret
   // appSecret 存在数据库或配置中心,不从请求中获取

5. 用同样的规则拼接签名串,计算 HMAC-SHA256
   // 客户端怎么拼,服务端就怎么拼,一个字符都不能差

6. 对比客户端传来的签名和自己算的签名
   不一致 → 内容被篡改,拒绝
   一致 → 验证通过,放行

注意第 3 步:nonce 存到 Redis 后要设置过期时间,和 timestamp 的窗口一致。这样过期的 nonce 会自动清理,不会无限增长。

流程清楚了,接下来就是代码落地。下面的实现用的全是 JDK 标准库,不需要引入任何外部依赖。


Java 实现

下面是完整的实现代码,可以直接用在项目里:

签名工具类

java 复制代码
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;

public class HmacSigner {

    // 签名算法,固定使用 HMAC-SHA256
    private static final String ALGORITHM = "HmacSHA256";
    // 密码学安全的随机数生成器,用于生成 nonce
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    /**
     * 计算 HMAC-SHA256 签名
     *
     * @param secret 密钥(appSecret,双方共享,不传输)
     * @param content 待签名的完整内容串
     * @return 大写十六进制签名字符串(64 位)
     */
    public static String sign(String secret, String content) {
        try {
            // 用密钥字节构造 SecretKeySpec
            SecretKeySpec keySpec = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), ALGORITHM);
            // 获取 HMAC 计算器
            Mac mac = Mac.getInstance(ALGORITHM);
            // 用密钥初始化
            mac.init(keySpec);
            // 传入内容字节,计算签名
            byte[] rawHmac = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
            // 转为大写十六进制字符串(64 个字符)
            return bytesToHex(rawHmac).toUpperCase();
        } catch (Exception e) {
            throw new RuntimeException("HMAC 签名计算失败", e);
        }
    }

    /**
     * 生成 32 位随机 nonce(16 字节 → 十六进制)
     * 每个请求必须生成一个新的 nonce,用于防重放
     */
    public static String generateNonce() {
        // 16 字节的密码学安全随机数
        byte[] bytes = new byte[16];
        SECURE_RANDOM.nextBytes(bytes);
        // 每个字节转成两位十六进制,拼成 32 位字符串
        StringBuilder sb = new StringBuilder(32);
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    /**
     * 字节数组转十六进制字符串
     */
    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(64);
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

签名参数 Record

java 复制代码
/**
 * 签名所需的全部参数
 */
public record HmacSignSpec(
    String appKey,
    String appSecret,
    String timestamp,
    String nonce,
    String body
) {}

发送请求时组装签名

java 复制代码
public class ApiClient {

    private final String appKey;     // 对方分配的应用标识
    private final String appSecret;  // 对方分配的密钥,只在本地使用,不传输

    /**
     * 构建带签名的认证 Header
     *
     * @param bodyJson 实际发送的请求体 JSON 字符串(签名和发送必须用同一个)
     * @return 包含四个认证 Header 的 HttpHeaders
     */
    public HttpHeaders buildAuthHeaders(String bodyJson) {
        // 1. 获取当前时间戳(毫秒)
        String timestamp = String.valueOf(System.currentTimeMillis());
        // 2. 生成一次性随机 nonce
        String nonce = HmacSigner.generateNonce();

        // 3. 拼接签名串:顺序必须固定,客户端和服务端要一致
        String content = "appKey=" + appKey
            + "&timestamp=" + timestamp
            + "&nonce=" + nonce
            + "&body=" + bodyJson;

        // 4. 用 appSecret 对签名串计算 HMAC-SHA256
        String signature = HmacSigner.sign(appSecret, content);

        // 5. 四个值分别放到 HTTP Header 里
        HttpHeaders headers = new HttpHeaders();
        headers.set("X-App-Key", appKey);      // 标识调用方
        headers.set("X-Timestamp", timestamp);  // 时间戳,服务端检查是否过期
        headers.set("X-Nonce", nonce);          // 随机数,服务端检查是否重放
        headers.set("X-Sign", signature);       // 签名,服务端验证完整性和真实性
        return headers;
    }
}

服务端验签(伪代码)

java 复制代码
public boolean verify(HttpServletRequest request, String bodyJson) {
    // 从 Header 中取出客户端传过来的四个认证字段
    String appKey = request.getHeader("X-App-Key");
    String timestamp = request.getHeader("X-Timestamp");
    String nonce = request.getHeader("X-Nonce");
    String clientSign = request.getHeader("X-Sign");

    // 第一步:时间窗口检查(5 分钟内有效)
    long requestTime = Long.parseLong(timestamp);
    if (Math.abs(System.currentTimeMillis() - requestTime) > 5 * 60 * 1000) {
        return false; // 请求已过期,直接拒绝
    }

    // 第二步:Nonce 唯一性检查(防重放的核心)
    String nonceKey = "api:nonce:" + nonce;
    if (redis.hasKey(nonceKey)) {
        return false; // 这个 nonce 已经用过了,是重放攻击
    }
    // 存入 Redis,过期时间和时间窗口一致,过期后自动清理
    redis.set(nonceKey, "1", 5, TimeUnit.MINUTES);

    // 第三步:用同样的规则拼接签名串,重新计算签名
    // appSecret 通过 appKey 从数据库/配置中查到,不从 Header 取
    String appSecret = getAppSecret(appKey);
    String content = "appKey=" + appKey
        + "&timestamp=" + timestamp
        + "&nonce=" + nonce
        + "&body=" + bodyJson;
    String expectedSign = HmacSigner.sign(appSecret, content);

    // 第四步:对比签名,一致则放行,不一致说明内容被篡改
    return expectedSign.equals(clientSign);
}

代码看起来不多,但实际用起来有几个地方特别容易翻车,都是踩过坑才知道的。


几个容易踩的坑

1. 签名串的顺序必须固定

appKey=xxx&timestamp=xxxtimestamp=xxx&appKey=xxx 算出来的签名完全不同。客户端和服务端必须用完全相同的顺序拼接。

建议在文档里明确写死顺序,不要用 Map 自动排序(不同语言的排序规则可能不同)。

2. body 要用实际发送的那个

签名用的 body 必须是实际 HTTP 请求里发的那个字符串,不能是"逻辑上等价"的另一个 JSON。

比如 {"a":1,"b":2}{"b":2,"a":1} 在逻辑上等价,但签名完全不同。建议发送前先做一次 JSON 压缩(去掉空格、固定 key 顺序),签名和发送用同一个字符串。

3. Nonce 的过期时间要和时间窗口一致

如果时间窗口是 5 分钟,nonce 的 Redis 过期时间也设 5 分钟。设太短会导致 nonce 被清理后还能重放;设太长会浪费内存。

4. 不要把 appSecret 放到签名串里

appSecret 是用来算 HMAC 的密钥,不能放到签名串内容里,更不能放到 HTTP Header 里。它只在本地参与计算,永远不传输。

5. SecureRandom,不要用 Random

nonce 必须是密码学安全的随机数。java.util.Random 是伪随机,种子可预测。必须用 java.security.SecureRandom

这些坑说大不大,但碰上一个就够排查半天的。说完这些,还有一个经常被问到的问题:既然有了 HTTPS,为什么还要搞应用层签名?


和 HTTPS 的关系

有人可能会问:用了 HTTPS,传输层已经是加密的了,还需要应用层签名吗?

答案是:看场景

HTTPS 保证的是传输层的安全------数据在你和服务端之间的链路上是加密的,中间人看不到也改不了。但它不保证:

  • 服务端收到的请求确实来自你(HTTPS 只保证传输通道安全,不保证请求内容可信)
  • 请求不能被服务端自己重放(比如服务端有恶意员工录下请求再发)

应用层签名解决的是端到端的安全------即使传输通道被攻破(比如证书被中间人劫持),签名仍然能检测篡改和重放。

对于内部系统之间的调用,HTTPS + API Key 通常够了。但对于对外开放的 API涉及资金操作的接口第三方平台对接,应用层签名是必要的。

理解了两者的定位之后,如果你的场景确实需要应用层签名,还可以在基础方案上做一些加强。


进阶:签名方案还能怎么扩展?

上面是最基础的版本。实际项目中,根据安全需求,还可以做这些扩展:

加入请求路径

把 URL Path 也放进签名串,防止攻击者把 A 接口的签名用到 B 接口:

复制代码
// 加入请求方法和路径,签名的有效范围更窄,安全性更高
content = "POST&/api/orders&appKey=xxx&timestamp=xxx&nonce=xxx&body=xxx"

加入请求方法

GET、POST、PUT 分开签,进一步缩小签名的有效范围。

使用不同的签名算法

HMAC-SHA256 是最常见的选择。如果对安全性有更高要求,可以用 HMAC-SHA512 或 Ed25519。但大多数场景下 SHA256 已经足够。

时间窗口动态调整

内部服务之间可以放宽到 10 分钟,对外开放的 API 收紧到 1 分钟。根据业务场景灵活配置。

说了这么多,最后把整个方案串起来回顾一下。


总结

回到开头的三个安全目标:

目标 解决方案
真实性 appKey + appSecret(只有双方知道密钥)
完整性 HMAC-SHA256 签名(篡改后签名不匹配)
新鲜性 timestamp + nonce(过期拒绝 + 一次性使用)

核心思路就一句话:用一个只有双方知道的密钥,对请求的关键信息算一个带密钥的哈希,附在请求里。服务端用同样的密钥和规则重新计算,对比结果。再加上时间戳和随机数防止重放。

整个方案没有引入任何外部依赖,用的都是 JDK 标准库(javax.crypto.Mac + java.security.SecureRandom),实现起来也就几十行代码。如果你的项目有对接第三方 API 的需求,不妨试试。

相关推荐
数智工坊1 小时前
周志华《Machine Learning》学习笔记--第九章--聚类
笔记·学习·机器学习
ch.ju1 小时前
Java程序设计(第3版)第四章——set-get方法
java·开发语言
lpd_lt1 小时前
如何让AI生成项目的单元测试,propmt技巧详解
java·人工智能·单元测试·ai编程
Amazing_Cacao1 小时前
CFCA精品可可品鉴师初级防御战:刺破营销故事幻象,划定极其硬核的瑕疵风味物理边界
学习
爱喝水的鱼丶1 小时前
SAP-ABAP:SAP 内存管理详解:从架构到优化
开发语言·学习·架构·sap·abap·内存管理
老H科研技术1 小时前
第 02 篇:5 分钟搭建第一个 MCP 服务器
大数据·运维·服务器·人工智能·学习·aigc·ai编程
Whoami!1 小时前
04-【政务】某市政务云安全架构
安全架构·政务·拓扑图
_日拱一卒1 小时前
LeetCode:17电话号码的字母组合
java·数据结构·算法·leetcode·职场和发展
我是一颗柠檬1 小时前
【Java项目技术亮点】Outbox事件驱动模式:解决分布式事务的终极方案
java·开发语言·分布式·后端·中间件·kafka