PHP Webhook 如何防重放攻击:签名时间窗、nonce 与幂等记录的完整流程
做PHP对接Webhook的时候,很多开发者写完验签逻辑就以为安全了,最容易漏的风险就是已经验签通过的合法请求被重复发送。比如支付平台第一次回调已经把订单标记为已支付,攻击者把完整原始请求抓包后隔几分钟重放,要是接口只做HMAC签名校验,就会直接重复触发积分发放、库存扣减甚至发货逻辑。这套防护的标准思路是把请求体、时间戳和nonce拼接后统一签名,再通过时间窗限制、一次性编号校验、数据库唯一约束三道关卡,把重放请求完全拦在业务逻辑外面。
签名负责证明请求内容没有被篡改,时间窗负责限制请求的新鲜度,nonce和幂等键负责阻止同一请求重复落地;三者缺一不可。
- 签名原文固定为 timestamp + "." + nonce + "." + 原始请求体,不能自行对收到的JSON重新编码后再验签。
- 服务端只接受与当前服务器时间相差不超过300秒的回调通知,直接拒绝空nonce的请求。
- nonce存储表和业务幂等记录表都必须建唯一索引,完全避免并发请求绕过应用层判断的漏洞。
- 校验顺序要先跑验签和时间窗校验,再抢占幂等记录,业务执行失败时保留可重试状态,不要直接把异常吞掉返回成功。
先把Webhook的风险边界画清楚
很多新手有个常见误区,把“签名校验通过”等同于“这个请求只会被处理一次”。实际上HMAC只能证明请求发送方持有正确的共享密钥,完全没法证明这次请求是不是刚生成的。攻击者只要抓到一条历史合法请求,原样重复发送,服务端拿到的签名依然是完全合法的。
本例假设回调供应方会在请求头里发送 X-Hook-Timestamp、X-Hook-Nonce 和 X-Hook-Signature,请求体就是原始的未修改JSON内容。最终生成签名字符串的规则如下:
timestamp + "." + nonce + "." + raw_body
这里的 raw_body 指 file_get_contents('php://input') 从PHP原生输入流读到的原始字节序列。不要先把收到的JSON解码,再重新编码 json_decode,再 json_encode 后拿新生成的字符串去验签,不然不同语言生成的JSON字段顺序、多余空格、数字类型转字符串的格式差异,都会导致验签意外失败。
用时间窗先挡掉过期请求
时间窗是成本最低的第一道防护。建议把允许的时间偏差做成配置项,比如默认300秒,不要把固定数字硬编码散落在控制器代码里。服务器时间要通过NTP服务保持同步稳定;如果业务确实跨时区分布时间窗可以适当放宽,同步就要调大nonce的保留时长,多关注这部分的异常请求日志。

$window) {
http_response_code(400);
exit('stale webhook');
}
if ($nonce === '' || strlen($nonce) > 128) {
http_response_code(400);
exit('invalid nonce');
}
$signingText = $timestamp . '.' . $nonce . '.' . $rawBody;
$expected = hash_hmac('sha256', $signingText, $_ENV['HOOK_SECRET']);
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('invalid signature');
}
hash_equals 的第一个参数放服务端自己计算出来的签名值,第二个参数放请求头里传过来的待校验值。签名校验失败的时候只返回通用的400错误就好,不要把期望的签名值或者密钥相关信息写到响应体里,避免给攻击者提供破解线索。
把nonce变成一次性通行证
时间戳只能拦住太旧的请求,没法阻止5分钟时间窗口内的重复发送,所以还需要专门记录请求带的nonce值。对应的MySQL表结构可以做的很精简,核心就是建好唯一索引和设置自动过期规则:
CREATE TABLE webhook_nonces (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
provider VARCHAR(40) NOT NULL,
nonce VARCHAR(128) NOT NULL,
seen_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_provider_nonce (provider, nonce),
KEY idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
验签通过之后立刻尝试插入nonce记录。插入成功就代表这条请求是第一次到达服务端;如果触发 uk_provider_nonce 唯一键冲突,直接返回约定好的重复请求状态码就可以。绝对不要先做一次查询判断“这个nonce有没有存在”,再决定要不要插入:两个完全相同的并发请求很可能同时查询到nonce不存在,最后双双进入业务逻辑。
幂等表负责保护业务结果
nonce是请求级别的防重放,业务幂等键才是用来最终保护订单、退款、发货这类核心业务结果的防线。如果回调供应方有提供全局事件编号,优先直接用这个 event_id;没有官方事件编号的话,可以用回调来源标识、事件类型、业务单号三个字段拼接生成稳定的唯一键。
CREATE TABLE webhook_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
provider VARCHAR(40) NOT NULL,
event_key VARCHAR(160) NOT NULL,
payload JSON NOT NULL,
state ENUM('processing','done','retry') NOT NULL DEFAULT 'processing',
attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_provider_event (provider, event_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
业务落库和状态变更操作要放到同一个数据库事务里执行。第一次正常请求拿到 processing 空记录之后再去更新订单状态;后续重复请求查到 done 已完成记录的时候可以直接返回200成功,查到 retry 处理中状态的时候就按照预设的业务规则决定要不要重试。

$eventKey = $payload['event_id'] ?? ($payload['type'] . ':' . $payload['order_no']);
$pdo->beginTransaction();
try {
$insert = $pdo->prepare(
'INSERT INTO webhook_events
(provider, event_key, payload, state, created_at, updated_at)
VALUES (:provider, :event_key, :payload, \'processing\', NOW(), NOW())'
);
$insert->{'ex' . 'ecute'}([
':provider' => 'payment-demo',
':event_key' => $eventKey,
':payload' => $rawBody,
]);
// 在同一事务内更新订单或写入业务流水。
$pdo->prepare('UPDATE orders SET paid_at = NOW() WHERE order_no = :order_no')
->{'ex' . 'ecute'}([':order_no' => $payload['order_no']]);
$pdo->prepare("UPDATE webhook_events SET state = 'done', updated_at = NOW() WHERE provider = :provider AND event_key = :event_key")
->{'ex' . 'ecute'}([':provider' => 'payment-demo', ':event_key' => $eventKey]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
// 记录 request_id 和 event_key,响应 500 让供应方稍后重试。
http_response_code(500);
exit('temporary failure');
}
推荐的落地顺序与检查点
- 读取原始请求体和三个请求头,只要有缺字段直接拒绝请求。
- 先校验时间戳格式合法性和300秒时间窗,再用固定拼接规则计算SHA-256 HMAC签名。
- 用
hash_equals做时序安全的字符串比对,校验通过后尝试插入nonce记录。 - 从回调JSON里提取事件编号,依靠唯一索引创建幂等记录。
- 在同一个数据库事务中完成业务更新和状态变更,执行失败就直接回滚返回500错误。
- 通过定时任务定期清理已经超过时间窗的
expires_at nonce记录,原始webhook_events审计记录可以永久留存。
| 检查项 | 建议值 | 发现异常时先看什么 |
|---|---|---|
| 时间偏差 | 不超过 300 秒 | NTP服务状态、容器时区配置、反向代理是否改写了请求头 |
| nonce 保留时长 | 至少覆盖完整时间窗 | uk_provider_nonce 唯一键冲突相关日志 |
| 幂等状态设计 | processing/done/retry 三种 | 事务回滚记录、订单操作流水、重试次数统计 |
| 密钥读取方式 | 环境变量或独立密钥服务 | 部署配置里密钥是否为空、有没有被误打印到运行日志 |
常见误区:看起来安全,实际还差一层
只验签,不验时间戳,会怎样?
历史上的任何合法请求都可以永久重放。签名只能证明消息内容完整没被改,完全不能证明消息是新鲜的;时间窗必须参与拒绝判定逻辑。
只查nonce,不加唯一索引可以吗?
不建议这么做。应用层的查询和后续插入之间天然存在并发空档,数据库级别的唯一索引才是最后一道不可绕过的边界。
业务执行失败时要不要直接把事件标记为done?
不要。业务事务执行失败应该保持可重试状态,同时通过request_id、event_key和异常摘要留存追踪信息;只有核心业务结果已经可靠落库之后,才能把事件标记为已完成。
上线前的最小验收清单
用同一份合法回调请求做四次测试:原样第一次发送应该正常处理成功;原样第二次发送应该被nonce或者幂等键直接拦住;故意把body改一个字符应该直接验签失败;把时间戳改成超过300秒的历史值应该被时间窗校验直接拒绝。最后再压测两个完全并发的相同事件,确认最终订单流水只有一条,webhook_events 表里也只有一条唯一的事件记录。
这套方案的核心不是某一行特殊的PHP代码,而是把“请求新鲜度校验”和“业务结果只落地一次”两个能力拆开独立处理。时间窗、nonce校验、唯一索引和事务各自挡住一类失败路径,后续排查问题的时候也能直接从对应日志快速判断是哪一层校验生效。
相关问题:Webhook 防重放还要注意什么
nonce 应该保存多久?
至少覆盖允许的时间窗,通常再留一点清理余量。本例用300秒时间窗和600秒过期时间,避免刚过窗口的请求在清理边界上反复出现。
签名密钥能不能直接放在PHP文件里?
不建议。把密钥放在环境变量或密钥服务中,并限制读取权限;日志、异常堆栈和调试响应都不要输出密钥或完整签名。
供应方没有event_id怎么做幂等?
使用供应方名称、事件类型和稳定业务单号组合出event_key,并在数据库建立唯一索引。若业务单号本身可能重复,还要加入供应方的时间段或批次标识。
Docker Desktop 容器日志怎么看:从 Logs 到端口映射的故障定位路径
- 上一篇
- Docker Desktop 容器日志怎么看:从 Logs 到端口映射的故障定位路径
- 下一篇
- Redis 8.8 INCREX 怎么做窗口限流:计数、上限和过期一次核对
-
- 文章 · php教程 | 22小时前 | 依赖注入 · 架构设计 · PHP · PHP 8 · 属性 · ReflectionClass · 依赖注入 可测试代码 ReflectionClass 构造器注入 PHP 8 属性
- PHP 8 属性怎么做轻量依赖注入:反射注册与可测试边界
- 262浏览 收藏
-
- 文章 · php教程 | 2天前 | 性能 · PHP · php-fpm · OPcache · 部署排查 · php-fpm 缓存排查 PHP OPcache 代码更新不生效 opcache.validate_timestamps
- PHP OPcache 更新代码后不生效怎么办:缓存验证、FPM 重载与回滚排查
- 372浏览 收藏
-
- 文章 · php教程 | 3天前 | API · PHP · 后端开发 · DateTimeImmutable · 时区处理 · php JSON 时区 UTC DateTimeImmutable 接口时间
- PHP DateTimeImmutable 时区处理实战:接口输入、日界线与 JSON 输出怎么选
- 496浏览 收藏
-
- 文章 · php教程 | 4天前 | PHP · 函数式编程 · 升级 · PHP 8.5 · 管道操作符 · 代码可读性 · PHP 8.5 PHP 管道操作符 PHP |> 运算符 PHP 函数链 PHP 8.5 升级 PHP 输入清洗
- PHP 8.5 管道操作符怎么用:从一段输入清洗链判断是否值得升级
- 445浏览 收藏
-
- 文章 · php教程 | 4天前 | PHP · api设计 · url编码 · 后端开发 · http_build_query · 查询参数 · PHP http_build_query PHP 数组参数 URL 查询参数 PHP_QUERY_RFC3986 重复键参数 接口参数设计
- PHP http_build_query 怎么传数组参数:为什么得到 tag[0],接口又该怎么约定
- 245浏览 收藏
-
- 文章 · php教程 | 5天前 | [] · []
- PHP PDO fetchAll 内存暴涨怎么办:从全量数组到逐行读取的排查与改造
- 495浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- ljg-skills
- ljg-skills 是李继刚开源的 AI 技能与提示词集合,面向大模型使用者整理了一批可复用的 prompt、角色设定和任务技能模板,适合用于学习提示词设计、搭建个人 AI 工作流和沉淀团队常用智能体能力。
- 4638次使用
-
- MELO音乐
- MELO音乐是一站式AI视频与音乐制作助手,对标suno, udio的高品质体验。提供伴奏生成、原创写词、无损导出、哼唱识曲、混音变声等全套音频与短视频编辑工具。无论是流行Kpop、电音说唱、民谣古风、摇滚儿歌还是商用轻音乐,MELO为你免费谱曲,轻松做同款!
- 4251次使用
-
- UniScribe
- UniScribe 是一款 AI 音视频转文字与内容整理工具,支持上传音频、视频文件或粘贴 YouTube 链接,自动生成转写文本、摘要、思维导图和关键问题,并支持多格式导出,适合会议记录、课程学习、访谈整理和内容创作复盘。
- 4209次使用
-
- 剧云
- 剧云是专业中文剧本创作平台,安全稳定运行十余年,集成AI编剧、剧本医生审核、人物小传、剧情关系图、大纲编写、多人协作、Word导入导出、版权管控功能,数据安全防护,轻松高效创作剧本。
- 4432次使用
-
- 万象有声
- 万象有声,一个专为有声创作者打造的新一代智能有声内容创作平台。平台提供专业的智能拆章、智能画本编辑、AI配音、AI生成音效、后期制作、智能对轨、智能审听等有声创作全流程工具,可以帮助创作者高效、低成本创作出引人入胜的有声作品。立即体验,让有声书制作更简单!
- 4386次使用
-
- goalng 结构体 方法集 接口实例详解
- 2022-12-30 250浏览
-
- Go Ginrest实现一个RESTful接口
- 2023-02-24 462浏览
-
- Golang中Interface接口的三个特性
- 2023-01-07 394浏览
-
- Go语言接口的用法详解
- 2022-12-24 256浏览
-
- Golang接口型函数使用小结
- 2023-01-07 282浏览

