调用第三方 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带密钥的哈希HMACHash-based Message Authentication Code的核心思想很简单在哈希计算过程中混入一个密钥这样只有持有密钥的人才能生成和验证签名。HMAC-SHA256 的计算公式// ⊕ 是异或XOR运算ipad 和 opad 是两个固定的填充常量 // 外层哈希套内层哈希密钥参与两次保证安全性 HMAC-SHA256(key, message) SHA256(key ⊕ opad || SHA256(key ⊕ ipad || message))看起来很复杂但你不需要记这个公式。实际使用时Java 标准库已经封装好了// 用密钥字节构造 SecretKeySpec指定算法为 HmacSHA256SecretKeySpeckeySpecnewSecretKeySpec(secret.getBytes(UTF_8),HmacSHA256);// 获取 HMAC 计算器实例MacmacMac.getInstance(HmacSHA256);// 用密钥初始化mac.init(keySpec);// 传入待签名内容计算签名byte[]resultmac.doFinal(message.getBytes(UTF_8));几行核心代码初始化 Mac、算签名。就这么简单。HMAC 保证了没有密钥的人既不能生成有效签名也不能验证签名是否正确。工具有了接下来就是怎么用的问题——签名串里到底该放哪些东西签名内容的设计放什么进去有了 HMAC接下来的问题是签名到底签什么最朴素的想法是只签请求体body。但这不够——攻击者可以不改 body而是把整个请求换个时间再发一次重放或者换个接口路径再发路由篡改。所以签名内容应该包含所有需要保护的信息。一个典型的签名串长这样appKeymy-app-keytimestamp1717500000000noncea1b2c3d4e5f6body{orderId:12345}四个部分每个都有明确的安全意义appKey标识调用方放在签名串里确保签名和调用方绑定。攻击者不能拿 A 的签名去冒充 B。注意签名串里放的是 appKey公开标识不是 appSecret密钥。appSecret 的角色是作为 HMAC 计算的密钥参与签名生成但它本身不会出现在签名串里也不会出现在 HTTP Header 里。它只在客户端本地和服务端本地各存一份。timestamp时间戳当前时间的毫秒数。它的作用是给请求加一个保质期——服务端收到请求后检查时间戳是否在可接受的窗口内比如 5 分钟。超过窗口的请求直接拒绝。这解决了一部分新鲜性问题但还不够。为什么因为 5 分钟窗口内同一个请求还是可以被重放。nonce随机数一个一次性的随机值。服务端维护一个已见过的 nonce 集合同一个 nonce 只能用一次。timestamp nonce 组合起来timestamp 限制了时间窗口nonce 保证窗口内不会重放。两者缺一不可只有 timestamp5 分钟内可以重放只有 noncenonce 永远不过期内存会爆body请求体需要防篡改的核心数据。注意是完整的请求体不是某个字段。理论讲完了下面用一个完整的例子把整个流程串起来。完整流程一步步走一遍假设我们要调用一个创建订单的 API。客户端发送方1. 生成 nonce16 字节密码学安全随机数 → 32 位十六进制字符串 // 每个请求一个绝不重复 nonce a1b2c3d4e5f67890a1b2c3d4e5f67890 2. 获取当前时间戳毫秒级 // 服务端会检查这个值是否在 5 分钟窗口内 timestamp 1717500000000 3. 准备请求体后续签名和实际发送要用同一个字符串 body {orderId:12345,amount:99.00} 4. 拼接签名串固定顺序不能乱 // 四个 key-value 用 连接顺序必须和服务端一致 content appKeymy-app-keytimestamp1717500000000noncea1b2c3d4...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-SHA256privatestaticfinalStringALGORITHMHmacSHA256;// 密码学安全的随机数生成器用于生成 nonceprivatestaticfinalSecureRandomSECURE_RANDOMnewSecureRandom();/** * 计算 HMAC-SHA256 签名 * * param secret 密钥appSecret双方共享不传输 * param content 待签名的完整内容串 * return 大写十六进制签名字符串64 位 */publicstaticStringsign(Stringsecret,Stringcontent){try{// 用密钥字节构造 SecretKeySpecSecretKeySpeckeySpecnewSecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),ALGORITHM);// 获取 HMAC 计算器MacmacMac.getInstance(ALGORITHM);// 用密钥初始化mac.init(keySpec);// 传入内容字节计算签名byte[]rawHmacmac.doFinal(content.getBytes(StandardCharsets.UTF_8));// 转为大写十六进制字符串64 个字符returnbytesToHex(rawHmac).toUpperCase();}catch(Exceptione){thrownewRuntimeException(HMAC 签名计算失败,e);}}/** * 生成 32 位随机 nonce16 字节 → 十六进制 * 每个请求必须生成一个新的 nonce用于防重放 */publicstaticStringgenerateNonce(){// 16 字节的密码学安全随机数byte[]bytesnewbyte[16];SECURE_RANDOM.nextBytes(bytes);// 每个字节转成两位十六进制拼成 32 位字符串StringBuildersbnewStringBuilder(32);for(byteb:bytes){sb.append(String.format(%02x,b));}returnsb.toString();}/** * 字节数组转十六进制字符串 */privatestaticStringbytesToHex(byte[]bytes){StringBuildersbnewStringBuilder(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. 获取当前时间戳毫秒StringtimestampString.valueOf(System.currentTimeMillis());// 2. 生成一次性随机 nonceStringnonceHmacSigner.generateNonce();// 3. 拼接签名串顺序必须固定客户端和服务端要一致StringcontentappKeyappKeytimestamptimestampnoncenoncebodybodyJson;// 4. 用 appSecret 对签名串计算 HMAC-SHA256StringsignatureHmacSigner.sign(appSecret,content);// 5. 四个值分别放到 HTTP Header 里HttpHeadersheadersnewHttpHeaders();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 中取出客户端传过来的四个认证字段StringappKeyrequest.getHeader(X-App-Key);Stringtimestamprequest.getHeader(X-Timestamp);Stringnoncerequest.getHeader(X-Nonce);StringclientSignrequest.getHeader(X-Sign);// 第一步时间窗口检查5 分钟内有效longrequestTimeLong.parseLong(timestamp);if(Math.abs(System.currentTimeMillis()-requestTime)5*60*1000){returnfalse;// 请求已过期直接拒绝}// 第二步Nonce 唯一性检查防重放的核心StringnonceKeyapi:nonce:nonce;if(redis.hasKey(nonceKey)){returnfalse;// 这个 nonce 已经用过了是重放攻击}// 存入 Redis过期时间和时间窗口一致过期后自动清理redis.set(nonceKey,1,5,TimeUnit.MINUTES);// 第三步用同样的规则拼接签名串重新计算签名// appSecret 通过 appKey 从数据库/配置中查到不从 Header 取StringappSecretgetAppSecret(appKey);StringcontentappKeyappKeytimestamptimestampnoncenoncebodybodyJson;StringexpectedSignHmacSigner.sign(appSecret,content);// 第四步对比签名一致则放行不一致说明内容被篡改returnexpectedSign.equals(clientSign);}代码看起来不多但实际用起来有几个地方特别容易翻车都是踩过坑才知道的。几个容易踩的坑1. 签名串的顺序必须固定appKeyxxxtimestampxxx和timestampxxxappKeyxxx算出来的签名完全不同。客户端和服务端必须用完全相同的顺序拼接。建议在文档里明确写死顺序不要用 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不要用 Randomnonce 必须是密码学安全的随机数。java.util.Random是伪随机种子可预测。必须用java.security.SecureRandom。这些坑说大不大但碰上一个就够排查半天的。说完这些还有一个经常被问到的问题既然有了 HTTPS为什么还要搞应用层签名和 HTTPS 的关系有人可能会问用了 HTTPS传输层已经是加密的了还需要应用层签名吗答案是看场景。HTTPS 保证的是传输层的安全——数据在你和服务端之间的链路上是加密的中间人看不到也改不了。但它不保证服务端收到的请求确实来自你HTTPS 只保证传输通道安全不保证请求内容可信请求不能被服务端自己重放比如服务端有恶意员工录下请求再发应用层签名解决的是端到端的安全——即使传输通道被攻破比如证书被中间人劫持签名仍然能检测篡改和重放。对于内部系统之间的调用HTTPS API Key 通常够了。但对于对外开放的 API、涉及资金操作的接口、第三方平台对接应用层签名是必要的。理解了两者的定位之后如果你的场景确实需要应用层签名还可以在基础方案上做一些加强。进阶签名方案还能怎么扩展上面是最基础的版本。实际项目中根据安全需求还可以做这些扩展加入请求路径把 URL Path 也放进签名串防止攻击者把 A 接口的签名用到 B 接口// 加入请求方法和路径签名的有效范围更窄安全性更高 content POST/api/ordersappKeyxxxtimestampxxxnoncexxxbodyxxx加入请求方法GET、POST、PUT 分开签进一步缩小签名的有效范围。使用不同的签名算法HMAC-SHA256 是最常见的选择。如果对安全性有更高要求可以用 HMAC-SHA512 或 Ed25519。但大多数场景下 SHA256 已经足够。时间窗口动态调整内部服务之间可以放宽到 10 分钟对外开放的 API 收紧到 1 分钟。根据业务场景灵活配置。说了这么多最后把整个方案串起来回顾一下。总结回到开头的三个安全目标目标解决方案真实性appKey appSecret只有双方知道密钥完整性HMAC-SHA256 签名篡改后签名不匹配新鲜性timestamp nonce过期拒绝 一次性使用核心思路就一句话用一个只有双方知道的密钥对请求的关键信息算一个带密钥的哈希附在请求里。服务端用同样的密钥和规则重新计算对比结果。再加上时间戳和随机数防止重放。整个方案没有引入任何外部依赖用的都是 JDK 标准库javax.crypto.Macjava.security.SecureRandom实现起来也就几十行代码。如果你的项目有对接第三方 API 的需求不妨试试。
API 签名防重放机制:基于 HMAC-SHA256 的设计与实现
发布时间:2026/6/6 19:41:09
调用第三方 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带密钥的哈希HMACHash-based Message Authentication Code的核心思想很简单在哈希计算过程中混入一个密钥这样只有持有密钥的人才能生成和验证签名。HMAC-SHA256 的计算公式// ⊕ 是异或XOR运算ipad 和 opad 是两个固定的填充常量 // 外层哈希套内层哈希密钥参与两次保证安全性 HMAC-SHA256(key, message) SHA256(key ⊕ opad || SHA256(key ⊕ ipad || message))看起来很复杂但你不需要记这个公式。实际使用时Java 标准库已经封装好了// 用密钥字节构造 SecretKeySpec指定算法为 HmacSHA256SecretKeySpeckeySpecnewSecretKeySpec(secret.getBytes(UTF_8),HmacSHA256);// 获取 HMAC 计算器实例MacmacMac.getInstance(HmacSHA256);// 用密钥初始化mac.init(keySpec);// 传入待签名内容计算签名byte[]resultmac.doFinal(message.getBytes(UTF_8));几行核心代码初始化 Mac、算签名。就这么简单。HMAC 保证了没有密钥的人既不能生成有效签名也不能验证签名是否正确。工具有了接下来就是怎么用的问题——签名串里到底该放哪些东西签名内容的设计放什么进去有了 HMAC接下来的问题是签名到底签什么最朴素的想法是只签请求体body。但这不够——攻击者可以不改 body而是把整个请求换个时间再发一次重放或者换个接口路径再发路由篡改。所以签名内容应该包含所有需要保护的信息。一个典型的签名串长这样appKeymy-app-keytimestamp1717500000000noncea1b2c3d4e5f6body{orderId:12345}四个部分每个都有明确的安全意义appKey标识调用方放在签名串里确保签名和调用方绑定。攻击者不能拿 A 的签名去冒充 B。注意签名串里放的是 appKey公开标识不是 appSecret密钥。appSecret 的角色是作为 HMAC 计算的密钥参与签名生成但它本身不会出现在签名串里也不会出现在 HTTP Header 里。它只在客户端本地和服务端本地各存一份。timestamp时间戳当前时间的毫秒数。它的作用是给请求加一个保质期——服务端收到请求后检查时间戳是否在可接受的窗口内比如 5 分钟。超过窗口的请求直接拒绝。这解决了一部分新鲜性问题但还不够。为什么因为 5 分钟窗口内同一个请求还是可以被重放。nonce随机数一个一次性的随机值。服务端维护一个已见过的 nonce 集合同一个 nonce 只能用一次。timestamp nonce 组合起来timestamp 限制了时间窗口nonce 保证窗口内不会重放。两者缺一不可只有 timestamp5 分钟内可以重放只有 noncenonce 永远不过期内存会爆body请求体需要防篡改的核心数据。注意是完整的请求体不是某个字段。理论讲完了下面用一个完整的例子把整个流程串起来。完整流程一步步走一遍假设我们要调用一个创建订单的 API。客户端发送方1. 生成 nonce16 字节密码学安全随机数 → 32 位十六进制字符串 // 每个请求一个绝不重复 nonce a1b2c3d4e5f67890a1b2c3d4e5f67890 2. 获取当前时间戳毫秒级 // 服务端会检查这个值是否在 5 分钟窗口内 timestamp 1717500000000 3. 准备请求体后续签名和实际发送要用同一个字符串 body {orderId:12345,amount:99.00} 4. 拼接签名串固定顺序不能乱 // 四个 key-value 用 连接顺序必须和服务端一致 content appKeymy-app-keytimestamp1717500000000noncea1b2c3d4...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-SHA256privatestaticfinalStringALGORITHMHmacSHA256;// 密码学安全的随机数生成器用于生成 nonceprivatestaticfinalSecureRandomSECURE_RANDOMnewSecureRandom();/** * 计算 HMAC-SHA256 签名 * * param secret 密钥appSecret双方共享不传输 * param content 待签名的完整内容串 * return 大写十六进制签名字符串64 位 */publicstaticStringsign(Stringsecret,Stringcontent){try{// 用密钥字节构造 SecretKeySpecSecretKeySpeckeySpecnewSecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),ALGORITHM);// 获取 HMAC 计算器MacmacMac.getInstance(ALGORITHM);// 用密钥初始化mac.init(keySpec);// 传入内容字节计算签名byte[]rawHmacmac.doFinal(content.getBytes(StandardCharsets.UTF_8));// 转为大写十六进制字符串64 个字符returnbytesToHex(rawHmac).toUpperCase();}catch(Exceptione){thrownewRuntimeException(HMAC 签名计算失败,e);}}/** * 生成 32 位随机 nonce16 字节 → 十六进制 * 每个请求必须生成一个新的 nonce用于防重放 */publicstaticStringgenerateNonce(){// 16 字节的密码学安全随机数byte[]bytesnewbyte[16];SECURE_RANDOM.nextBytes(bytes);// 每个字节转成两位十六进制拼成 32 位字符串StringBuildersbnewStringBuilder(32);for(byteb:bytes){sb.append(String.format(%02x,b));}returnsb.toString();}/** * 字节数组转十六进制字符串 */privatestaticStringbytesToHex(byte[]bytes){StringBuildersbnewStringBuilder(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. 获取当前时间戳毫秒StringtimestampString.valueOf(System.currentTimeMillis());// 2. 生成一次性随机 nonceStringnonceHmacSigner.generateNonce();// 3. 拼接签名串顺序必须固定客户端和服务端要一致StringcontentappKeyappKeytimestamptimestampnoncenoncebodybodyJson;// 4. 用 appSecret 对签名串计算 HMAC-SHA256StringsignatureHmacSigner.sign(appSecret,content);// 5. 四个值分别放到 HTTP Header 里HttpHeadersheadersnewHttpHeaders();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 中取出客户端传过来的四个认证字段StringappKeyrequest.getHeader(X-App-Key);Stringtimestamprequest.getHeader(X-Timestamp);Stringnoncerequest.getHeader(X-Nonce);StringclientSignrequest.getHeader(X-Sign);// 第一步时间窗口检查5 分钟内有效longrequestTimeLong.parseLong(timestamp);if(Math.abs(System.currentTimeMillis()-requestTime)5*60*1000){returnfalse;// 请求已过期直接拒绝}// 第二步Nonce 唯一性检查防重放的核心StringnonceKeyapi:nonce:nonce;if(redis.hasKey(nonceKey)){returnfalse;// 这个 nonce 已经用过了是重放攻击}// 存入 Redis过期时间和时间窗口一致过期后自动清理redis.set(nonceKey,1,5,TimeUnit.MINUTES);// 第三步用同样的规则拼接签名串重新计算签名// appSecret 通过 appKey 从数据库/配置中查到不从 Header 取StringappSecretgetAppSecret(appKey);StringcontentappKeyappKeytimestamptimestampnoncenoncebodybodyJson;StringexpectedSignHmacSigner.sign(appSecret,content);// 第四步对比签名一致则放行不一致说明内容被篡改returnexpectedSign.equals(clientSign);}代码看起来不多但实际用起来有几个地方特别容易翻车都是踩过坑才知道的。几个容易踩的坑1. 签名串的顺序必须固定appKeyxxxtimestampxxx和timestampxxxappKeyxxx算出来的签名完全不同。客户端和服务端必须用完全相同的顺序拼接。建议在文档里明确写死顺序不要用 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不要用 Randomnonce 必须是密码学安全的随机数。java.util.Random是伪随机种子可预测。必须用java.security.SecureRandom。这些坑说大不大但碰上一个就够排查半天的。说完这些还有一个经常被问到的问题既然有了 HTTPS为什么还要搞应用层签名和 HTTPS 的关系有人可能会问用了 HTTPS传输层已经是加密的了还需要应用层签名吗答案是看场景。HTTPS 保证的是传输层的安全——数据在你和服务端之间的链路上是加密的中间人看不到也改不了。但它不保证服务端收到的请求确实来自你HTTPS 只保证传输通道安全不保证请求内容可信请求不能被服务端自己重放比如服务端有恶意员工录下请求再发应用层签名解决的是端到端的安全——即使传输通道被攻破比如证书被中间人劫持签名仍然能检测篡改和重放。对于内部系统之间的调用HTTPS API Key 通常够了。但对于对外开放的 API、涉及资金操作的接口、第三方平台对接应用层签名是必要的。理解了两者的定位之后如果你的场景确实需要应用层签名还可以在基础方案上做一些加强。进阶签名方案还能怎么扩展上面是最基础的版本。实际项目中根据安全需求还可以做这些扩展加入请求路径把 URL Path 也放进签名串防止攻击者把 A 接口的签名用到 B 接口// 加入请求方法和路径签名的有效范围更窄安全性更高 content POST/api/ordersappKeyxxxtimestampxxxnoncexxxbodyxxx加入请求方法GET、POST、PUT 分开签进一步缩小签名的有效范围。使用不同的签名算法HMAC-SHA256 是最常见的选择。如果对安全性有更高要求可以用 HMAC-SHA512 或 Ed25519。但大多数场景下 SHA256 已经足够。时间窗口动态调整内部服务之间可以放宽到 10 分钟对外开放的 API 收紧到 1 分钟。根据业务场景灵活配置。说了这么多最后把整个方案串起来回顾一下。总结回到开头的三个安全目标目标解决方案真实性appKey appSecret只有双方知道密钥完整性HMAC-SHA256 签名篡改后签名不匹配新鲜性timestamp nonce过期拒绝 一次性使用核心思路就一句话用一个只有双方知道的密钥对请求的关键信息算一个带密钥的哈希附在请求里。服务端用同样的密钥和规则重新计算对比结果。再加上时间戳和随机数防止重放。整个方案没有引入任何外部依赖用的都是 JDK 标准库javax.crypto.Macjava.security.SecureRandom实现起来也就几十行代码。如果你的项目有对接第三方 API 的需求不妨试试。