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 对应的权限,没问题就返回数据。
跑了一段时间,测试环境一切正常。但安全评审的时候被挑战了几个问题:
- appKey 是明文传输的——如果有人抓到这个请求,他可以直接拿 appKey 去调 API,我们完全拦不住。
- 请求体可以被篡改——比如我们发的是"查订单 A",中间人改成"查订单 B",服务端根本发现不了。
- 请求可以被重放——攻击者录下我们的一个请求,反复发送,每次都有效。
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 标准库已经封装好了:
// 用密钥字节构造 SecretKeySpec,指定算法为 HmacSHA256SecretKeySpeckeySpec=newSecretKeySpec(secret.getBytes(UTF_8),"HmacSHA256");// 获取 HMAC 计算器实例Macmac=Mac.getInstance("HmacSHA256");// 用密钥初始化mac.init(keySpec);// 传入待签名内容,计算签名byte[]result=mac.doFinal(message.getBytes(UTF_8));几行核心代码:初始化 Mac、算签名。就这么简单。
HMAC 保证了:没有密钥的人,既不能生成有效签名,也不能验证签名是否正确。
工具有了,接下来就是怎么用的问题——签名串里到底该放哪些东西?
签名内容的设计:放什么进去?
有了 HMAC,接下来的问题是:签名到底签什么?
最朴素的想法是只签请求体(body)。但这不够——攻击者可以不改 body,而是把整个请求换个时间再发一次(重放),或者换个接口路径再发(路由篡改)。
所以签名内容应该包含所有需要保护的信息。一个典型的签名串长这样:
appKey=my-app-key×tamp=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×tamp=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 实现
下面是完整的实现代码,可以直接用在项目里:
签名工具类
importjavax.crypto.Mac;importjavax.crypto.spec.SecretKeySpec;importjava.nio.charset.StandardCharsets;importjava.security.SecureRandom;publicclassHmacSigner{// 签名算法,固定使用 HMAC-SHA256privatestaticfinalStringALGORITHM="HmacSHA256";// 密码学安全的随机数生成器,用于生成 nonceprivatestaticfinalSecureRandomSECURE_RANDOM=newSecureRandom();/** * 计算 HMAC-SHA256 签名 * * @param secret 密钥(appSecret,双方共享,不传输) * @param content 待签名的完整内容串 * @return 大写十六进制签名字符串(64 位) */publicstaticStringsign(Stringsecret,Stringcontent){try{// 用密钥字节构造 SecretKeySpecSecretKeySpeckeySpec=newSecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),ALGORITHM);// 获取 HMAC 计算器Macmac=Mac.getInstance(ALGORITHM);// 用密钥初始化mac.init(keySpec);// 传入内容字节,计算签名byte[]rawHmac=mac.doFinal(content.getBytes(StandardCharsets.UTF_8));// 转为大写十六进制字符串(64 个字符)returnbytesToHex(rawHmac).toUpperCase();}catch(Exceptione){thrownewRuntimeException("HMAC 签名计算失败",e);}}/** * 生成 32 位随机 nonce(16 字节 → 十六进制) * 每个请求必须生成一个新的 nonce,用于防重放 */publicstaticStringgenerateNonce(){// 16 字节的密码学安全随机数byte[]bytes=newbyte[16];SECURE_RANDOM.nextBytes(bytes);// 每个字节转成两位十六进制,拼成 32 位字符串StringBuildersb=newStringBuilder(32);for(byteb:bytes){sb.append(String.format("%02x",b));}returnsb.toString();}/** * 字节数组转十六进制字符串 */privatestaticStringbytesToHex(byte[]bytes){StringBuildersb=newStringBuilder(64);for(byteb:bytes){sb.append(String.format("%02x",b));}returnsb.toString();}}签名参数 Record
/** * 签名所需的全部参数 */publicrecordHmacSignSpec(StringappKey,StringappSecret,Stringtimestamp,Stringnonce,Stringbody){}发送请求时组装签名
publicclassApiClient{privatefinalStringappKey;// 对方分配的应用标识privatefinalStringappSecret;// 对方分配的密钥,只在本地使用,不传输/** * 构建带签名的认证 Header * * @param bodyJson 实际发送的请求体 JSON 字符串(签名和发送必须用同一个) * @return 包含四个认证 Header 的 HttpHeaders */publicHttpHeadersbuildAuthHeaders(StringbodyJson){// 1. 获取当前时间戳(毫秒)Stringtimestamp=String.valueOf(System.currentTimeMillis());// 2. 生成一次性随机 nonceStringnonce=HmacSigner.generateNonce();// 3. 拼接签名串:顺序必须固定,客户端和服务端要一致Stringcontent="appKey="+appKey+"×tamp="+timestamp+"&nonce="+nonce+"&body="+bodyJson;// 4. 用 appSecret 对签名串计算 HMAC-SHA256Stringsignature=HmacSigner.sign(appSecret,content);// 5. 四个值分别放到 HTTP Header 里HttpHeadersheaders=newHttpHeaders();headers.set("X-App-Key",appKey);// 标识调用方headers.set("X-Timestamp",timestamp);// 时间戳,服务端检查是否过期headers.set("X-Nonce",nonce);// 随机数,服务端检查是否重放headers.set("X-Sign",signature);// 签名,服务端验证完整性和真实性returnheaders;}}服务端验签(伪代码)
publicbooleanverify(HttpServletRequestrequest,StringbodyJson){// 从 Header 中取出客户端传过来的四个认证字段StringappKey=request.getHeader("X-App-Key");Stringtimestamp=request.getHeader("X-Timestamp");Stringnonce=request.getHeader("X-Nonce");StringclientSign=request.getHeader("X-Sign");// 第一步:时间窗口检查(5 分钟内有效)longrequestTime=Long.parseLong(timestamp);if(Math.abs(System.currentTimeMillis()-requestTime)>5*60*1000){returnfalse;// 请求已过期,直接拒绝}// 第二步:Nonce 唯一性检查(防重放的核心)StringnonceKey="api:nonce:"+nonce;if(redis.hasKey(nonceKey)){returnfalse;// 这个 nonce 已经用过了,是重放攻击}// 存入 Redis,过期时间和时间窗口一致,过期后自动清理redis.set(nonceKey,"1",5,TimeUnit.MINUTES);// 第三步:用同样的规则拼接签名串,重新计算签名// appSecret 通过 appKey 从数据库/配置中查到,不从 Header 取StringappSecret=getAppSecret(appKey);Stringcontent="appKey="+appKey+"×tamp="+timestamp+"&nonce="+nonce+"&body="+bodyJson;StringexpectedSign=HmacSigner.sign(appSecret,content);// 第四步:对比签名,一致则放行,不一致说明内容被篡改returnexpectedSign.equals(clientSign);}代码看起来不多,但实际用起来有几个地方特别容易翻车,都是踩过坑才知道的。
几个容易踩的坑
1. 签名串的顺序必须固定
appKey=xxx×tamp=xxx和timestamp=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×tamp=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 的需求,不妨试试。
