登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP Webhook 如何防重放攻击:签名时间窗、nonce 与幂等记录的完整流程

来源:17golang原创

时间:2026-07-21 11:36:26 335浏览 收藏

做PHP对接Webhook的时候,很多开发者写完验签逻辑就以为安全了,最容易漏的风险就是已经验签通过的合法请求被重复发送。比如支付平台第一次回调已经把订单标记为已支付,攻击者把完整原始请求抓包后隔几分钟重放,要是接口只做HMAC签名校验,就会直接重复触发积分发放、库存扣减甚至发货逻辑。这套防护的标准思路是把请求体、时间戳和nonce拼接后统一签名,再通过时间窗限制、一次性编号校验、数据库唯一约束三道关卡,把重放请求完全拦在业务逻辑外面。

签名负责证明请求内容没有被篡改,时间窗负责限制请求的新鲜度,nonce和幂等键负责阻止同一请求重复落地;三者缺一不可。

要点速览
  • 签名原文固定为 timestamp + "." + nonce + "." + 原始请求体,不能自行对收到的JSON重新编码后再验签。
  • 服务端只接受与当前服务器时间相差不超过300秒的回调通知,直接拒绝空nonce的请求。
  • nonce存储表和业务幂等记录表都必须建唯一索引,完全避免并发请求绕过应用层判断的漏洞。
  • 校验顺序要先跑验签和时间窗校验,再抢占幂等记录,业务执行失败时保留可重试状态,不要直接把异常吞掉返回成功。

先把Webhook的风险边界画清楚

很多新手有个常见误区,把“签名校验通过”等同于“这个请求只会被处理一次”。实际上HMAC只能证明请求发送方持有正确的共享密钥,完全没法证明这次请求是不是刚生成的。攻击者只要抓到一条历史合法请求,原样重复发送,服务端拿到的签名依然是完全合法的。

本例假设回调供应方会在请求头里发送 X-Hook-TimestampX-Hook-NonceX-Hook-Signature,请求体就是原始的未修改JSON内容。最终生成签名字符串的规则如下:

timestamp + "." + nonce + "." + raw_body

这里的 raw_bodyfile_get_contents('php://input') 从PHP原生输入流读到的原始字节序列。不要先把收到的JSON解码,再重新编码 json_decode,再 json_encode 后拿新生成的字符串去验签,不然不同语言生成的JSON字段顺序、多余空格、数字类型转字符串的格式差异,都会导致验签意外失败。

用时间窗先挡掉过期请求

时间窗是成本最低的第一道防护。建议把允许的时间偏差做成配置项,比如默认300秒,不要把固定数字硬编码散落在控制器代码里。服务器时间要通过NTP服务保持同步稳定;如果业务确实跨时区分布时间窗可以适当放宽,同步就要调大nonce的保留时长,多关注这部分的异常请求日志。

PHP Webhook 时间窗防重放示意:时间戳、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 处理中状态的时候就按照预设的业务规则决定要不要重试。

PHP Webhook 幂等记录资源预算图:并发重复请求在唯一索引处收敛为一次业务落地
$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');
}

推荐的落地顺序与检查点

  1. 读取原始请求体和三个请求头,只要有缺字段直接拒绝请求。
  2. 先校验时间戳格式合法性和300秒时间窗,再用固定拼接规则计算SHA-256 HMAC签名。
  3. hash_equals 做时序安全的字符串比对,校验通过后尝试插入nonce记录。
  4. 从回调JSON里提取事件编号,依靠唯一索引创建幂等记录。
  5. 在同一个数据库事务中完成业务更新和状态变更,执行失败就直接回滚返回500错误。
  6. 通过定时任务定期清理已经超过时间窗的 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,并在数据库建立唯一索引。若业务单号本身可能重复,还要加入供应方的时间段或批次标识。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>