Java Webhook 签名校验实战:用时间戳和 HMAC 阻断重放请求
来源:17golang原创
时间:2026-07-26 14:53:14 215浏览 收藏
订单系统收到第三方 Webhook 后,最麻烦的情况不是接口返回 500,而是同一笔回调隔几分钟又来一次,业务却已经执行过。只把请求头里的密钥比对一下,无法区分“刚发来的请求”和“被截获后重新发送的旧请求”。
一个可落地的校验组合是:用原始请求体参与 HMAC-SHA256 签名,再把时间戳放进签名输入,并限制请求只能在短时间窗口内到达。验签通过后还要用业务事件 ID 做幂等处理。
要点速览
- 签名必须基于原始请求体,不能基于解析后重新拼接的 JSON。
- 时间戳负责缩短重放窗口,业务事件 ID 负责避免重复落库。
- 比较签名时使用 MessageDigest.isEqual,避免普通字符串比较带来的时序差异。
- 验收至少覆盖正常请求、过期请求和请求体被改动三种结果。
先做一个能验收的 Webhook 小服务
为了把边界讲清楚,示例只保留一个回调入口。请求方发送三个请求头:X-Webhook-Timestamp、X-Webhook-Id 和 X-Webhook-Signature。签名原文采用 时间戳 + "." + 原始请求体,再用共享密钥计算 HMAC-SHA256。
String signingText = timestamp + "." + rawBody;
String signature = hmacSha256Hex(secret, signingText);
if (!sameSignature(signature, receivedSignature)) {
return ResponseEntity.status(401).body("signature rejected");
}
return ResponseEntity.ok("accepted");
这里的 rawBody 是 HTTP 请求刚读出的字节转成的 UTF-8 文本。不要先把 JSON 反序列化成对象,再用对象重新序列化去签名;字段顺序、空格和转义方式稍有不同,合法请求也会验签失败。

用 HMAC-SHA256 校验原始请求体
Java 标准库已经提供了 Mac 和 SecretKeySpec,不需要自己实现哈希算法。生产代码应从密钥管理系统读取密钥;为了方便本地运行,下面把密钥作为构造参数传入。
private static String hmacSha256Hex(String secret, String text) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte value : digest) {
hex.append(String.format("%02x", value));
}
return hex.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("cannot build webhook signature", e);
}
}
private static boolean sameSignature(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.US_ASCII),
actual.getBytes(StandardCharsets.US_ASCII));
}
验签函数只负责判断签名是否一致,不负责确认事件是否处理过。两件事拆开后,密钥轮换、错误统计和业务幂等都更容易测试。
再加时间窗口,拦住旧请求重放
攻击者即使拿到一份完整的合法请求,也不应该在很久以后再次提交。服务端读取时间戳后,先计算它与本机当前时间的差值。示例把允许窗口设为 300 秒;实际值要结合第三方的重试策略和网络延迟调整。
private static boolean withinWindow(String timestamp, long nowSeconds) {
try {
long sentAt = Long.parseLong(timestamp);
return Math.abs(nowSeconds - sentAt)
校验顺序建议是先检查时间戳格式和窗口,再计算签名,最后进入业务幂等判断。窗口过期时直接返回明确的 401 或 400,并记录事件 ID;不要把完整请求体写入普通日志。

把事件 ID 接到幂等处理上
签名正确只说明请求来自持有密钥的一方,不代表它是第一次到达。收到 X-Webhook-Id 后,可以在数据库建立唯一索引,例如 webhook_event(event_id)。插入成功才执行业务;唯一键冲突时返回已处理状态。
CREATE TABLE webhook_event (
event_id VARCHAR(80) PRIMARY KEY,
received_at TIMESTAMP NOT NULL,
payload_hash CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL
);
这样即使第三方在网络超时后重试,服务也不会把同一个事件再次转成订单动作。若业务必须区分“处理中”和“已完成”,把状态更新放在同一事务边界内,并为长时间卡住的处理中记录准备人工或定时补偿路径。
用三组请求确认结果
本地验收时固定请求体和事件 ID,先生成签名,再分别改变时间戳或 JSON 内容。不要只测试一条成功请求,那只能证明代码能走通,不能证明边界有效。
body='{"eventId":"evt_1001","type":"order.paid"}'
ts=$(date +%s)
signature=$(./sign-webhook "$WEBHOOK_SECRET" "$ts.$body")
curl -i http://localhost:8080/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $ts" \
-H "X-Webhook-Id: evt_1001" \
-H "X-Webhook-Signature: $signature" \
--data "$body"
- 正常请求:返回 200,事件表新增
evt_1001。 - 重复请求:签名仍然正确,但唯一键命中,业务动作不再执行。
- 过期请求:返回 401,日志只保留事件 ID、失败原因和时间差。
- 篡改请求:保持旧签名但修改
type,返回 401。
常见问题
为什么不能只比较一个固定 Authorization?
固定密钥能证明请求方知道秘密,却不能证明请求是新鲜的。时间戳和请求体一起参与签名,才能让旧报文在窗口之外失效。
JSON 空格变化会导致验签失败吗?
会。签名针对的是原始字节序列,所以服务端应在解析 JSON 前完成验签。
时间窗口设得越短越安全吗?
不一定。窗口过短会误伤网络抖动和第三方重试;应根据真实延迟指标设置,并配合事件 ID 幂等。
验签通过后还需要做什么?
还要校验事件类型、字段范围、事件 ID 唯一性和业务状态。密码学通过不是业务数据可信的全部证明。
小结
这个小服务的核心不是某个框架注解,而是把四个判断按顺序固定下来:原始请求体是否完整、时间戳是否在窗口内、HMAC 是否匹配、事件 ID 是否已经处理。先用三组请求验收,再接入真实业务,排错时会比“回调偶尔重复”更容易定位。
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
文章 · java教程 | 2小时前 | 精度 · Java教程 · BigDecimal · 金额计算 · 异常排查 · 精度 ArithmeticException 金额计算 RoundingMode Java BigDecimal.divide455 收藏
-
文章 · java教程 | 7小时前 | 密码学 · Java教程 · 安全编程 · Java 25 · 密钥派生 · aes Java 25 KDF HKDF JEP 510 HKDFParameterSpec183 收藏
-
文章 · java教程 | 7小时前 | Java教程 · Java 25 · 语言特性 · 模式匹配 · 代码实践 · Switch instanceof Java 25 原始类型模式 JEP 507 when315 收藏
-
文章 · java教程 | 10小时前 | 命令行工具 · Java教程 · Java 25 · 语言特性 · 入门实践 · Java 25 Compact Source Files instance main JEP 512 java.lang.IO478 收藏
-
文章 · java教程 | 1天前 | 面向对象 · Java教程 · Java 25 · 语言特性 · 构造器 · Java 25 JEP 513 Flexible Constructor Bodies super 构造器 Java 构造器200 收藏
-
文章 · java教程 | 1天前 | 面向对象 · Java教程 · Java 25 · 语言特性 · 构造器 · Java 25 JEP 513 Flexible Constructor Bodies super 构造器 Java 构造器271 收藏
-
文章 · java教程 | 1天前 | 反序列化 · Java教程 · 安全编程 · Java IO · java Java安全 ObjectInputFilter 反序列化过滤 Serialization Filter257 收藏
-
文章 · java教程 | 1天前 | 反序列化 · Java教程 · 安全编程 · Java IO · java Java安全 ObjectInputFilter 反序列化过滤 Serialization Filter186 收藏
-
文章 · java教程 | 2天前 | 密码学 · Java教程 · Java 25 · HKDF · 密钥派生 · Java 25 KDF Key Derivation Function API HKDF JEP 510 密钥派生147 收藏
-
143 收藏
-
270 收藏
-
文章 · java教程 | 2天前 | Java教程 · Java 24 · 字节码 · Class-File API · JVM工具 · 字节码 Java 24 Class-File API ClassModel ASM替代 java.lang.classfile352 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习