所有文章
webhook debuggingHTTP errorsauthenticationtroubleshooting

为什么你的 webhook 返回 401 或 403(以及如何修复)

返回 401 或 403 的 webhook 落入少数几个类别。多数时候它甚至不是签名问题——而是中间件或框架默认值在你的代码运行之前就拒绝了请求。这是我们使用的诊断清单,按我们使用它的顺序排列。

首先,区分 401 和 403

它们含义不同,修复方式也不同。

401 Unauthorized:服务器收到了请求,查看了凭据(或签名),没有接受。你的处理器很可能运行了,计算了一个期望签名,然后拒绝了。

403 Forbidden:服务器收到了请求,并因其他原因拒绝处理。请求往往根本没到达你的处理器——是中间件或框架默认值发出了 403。

打开你隧道的请求捕获,看看响应。如果你能看到处理器的响应体(“invalid signature”),那是来自你代码的 401。如果响应是通用的且你的处理器日志什么都没有,那是框架在你的处理器运行前就拒绝了。

五个最可能的原因

1. CSRF 中间件(Django、Rails、Laravel)

默认 CSRF 保护会拒绝没有会话令牌的 POST。webhook 提供商不会发送。症状:403、通用响应体、无处理器日志。

修复:把 webhook 路由从 CSRF 保护中排除。Django 用 @csrf_exempt。Rails 用 skip_before_action :verify_authenticity_token, only: [:webhook]。Laravel 把路径加入 VerifyCsrfToken::$exceptDjango webhook 指南从头到尾讲解了 Django 版本。

2. 鉴权中间件应用得太宽

你全局添加了鉴权中间件(JWT、会话检查、API key 要求),webhook 路由继承了它。提供商不会发送你的鉴权头,于是中间件在你的处理器运行前就发出 401。

修复:把 webhook 路径从你的鉴权中间件中排除。webhook 的鉴权是签名,而不是保护你 API 其余部分的方案。

3. 签名验证失败

你的处理器运行了,计算了期望签名,但不匹配。大致按频率从高到低的五个子原因:

  • 验证运行前请求体被中间件解析了(原始请求体没了)。
  • 编码错误(hex vs base64)。见签名验证指南
  • 密钥错误(test vs live、dashboard vs CLI、env 文件 vs 运行时)。
  • 时间戳太旧(签名有效但过期——很可能你在用旧的重放载荷测试)。
  • 从 env 文件加载的密钥末尾有空白。

4. 注册了错误的隧道 URL

你重启了隧道,URL 轮换了,但提供商仪表盘里还是旧的。症状看起来像 401,因为请求从未到达你——但实际上是另一个请求到达了另一个服务器(通常是之前的隧道会话,现在拒绝或返回 401)。

修复:确认提供商仪表盘里的 URL 与你当前的隧道会话一致。如需稳定 URL,看看命名隧道或保留子域名。

5. CORS、content-type 或方法限制

对 webhook 较少见但可能。如果你的路由只接受 application/json 而提供商发送 application/x-www-form-urlencoded(比如 Twilio),有些框架返回 415——但配置错误的可能返回 403。或者你的路由注册为 GET 而提供商用 POST。

90 秒诊断流程

这是我们走的顺序:

  1. 读响应体。如果里面有你处理器的话,请求到达了处理器。转去调试签名。如果是通用的框架错误页,请求没到达处理器。转去调试中间件。
  2. 检查处理器日志。你处理器里有任何日志语句在触发吗?确认请求是否到达你。
  3. 检查注册的 URL。打开提供商仪表盘。确认 URL 与你当前隧道一致。确认它指向正确路径。
  4. 对比传入头部。隧道捕获向你展示提供商发送的确切头部。与你代码读取的对比。头部不区分大小写,但访问方式因框架而异——有的用 request.headers.get('Stripe-Signature'),有的用 request.META['HTTP_STRIPE_SIGNATURE']
  5. 验证签名时请求体是原始的。在计算签名前打印请求体长度。如果为零或小得离谱,是中间件吃掉了它。
  6. 再次核对密钥。把运行时的 env 变量与仪表盘对比。console.log(process.env.WEBHOOK_SECRET.length)——长度与仪表盘显示的一致吗?

以我们的经验,第 1 步和第 5 步在头两分钟内抓住 80% 的情况。

捕获失败的请求

这里最有用的单一工具是带请求捕获与重放的隧道。你无需等提供商重试——调试时把捕获的请求重放到你的本地处理器。每次尝试都是即时的。

如果你用的隧道不捕获请求,那等于一只手被绑在背后调试。换一个能捕获的(或运行 tcpdump,或在开发服务器前放 nginx),在你第一次省下一小时时就回本了。

各提供商看到的 401 长什么样

  • Stripe:来自你代码的 401 意味着签名验证失败。Stripe 仪表盘把投递显示为失败并包含你的响应体。
  • GitHub:如果你的处理器返回 401,GitHub 把投递标记为失败并重试。最近投递页面显示响应。
  • Shopify:来自 webhook 处理器的 401 出于安全是可以的,但 Shopify 会重试。48 小时内连续 19 次失败后,Shopify 会禁用该订阅。

关于更广泛的 webhook 调试背景,见如何在本地调试 webhook加入 PortPreview 等候名单,获得内置捕获的隧道。

常见问题

webhook 上的 401 是什么意思?
你的处理器收到了请求,评估了凭据或签名,并拒绝了它们。最常见的是签名验证失败,原因是验证前就解析了请求体、编码错误(hex vs base64)或密钥错误。
为什么 webhook 提供商从我的应用看到 403?
403 通常意味着请求从未到达你的处理器。最常见的原因是 CSRF 中间件(在 Django、Rails、Laravel 中)或应用得太宽的鉴权中间件。把 webhook 路由从 CSRF 和通用 API 鉴权中排除,好让请求能到达你的验证代码。
如何在不重新触发事件的情况下调试 webhook 鉴权失败?
使用带请求捕获与重放的隧道。从提供商捕获一次投递,然后在调试时把它重放到本地处理器,想多少次就多少次。每次重放都是即时的,且不依赖提供商。