返回 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::$except。Django 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 秒诊断流程
这是我们走的顺序:
- 读响应体。如果里面有你处理器的话,请求到达了处理器。转去调试签名。如果是通用的框架错误页,请求没到达处理器。转去调试中间件。
- 检查处理器日志。你处理器里有任何日志语句在触发吗?确认请求是否到达你。
- 检查注册的 URL。打开提供商仪表盘。确认 URL 与你当前隧道一致。确认它指向正确路径。
- 对比传入头部。隧道捕获向你展示提供商发送的确切头部。与你代码读取的对比。头部不区分大小写,但访问方式因框架而异——有的用
request.headers.get('Stripe-Signature'),有的用request.META['HTTP_STRIPE_SIGNATURE']。 - 验证签名时请求体是原始的。在计算签名前打印请求体长度。如果为零或小得离谱,是中间件吃掉了它。
- 再次核对密钥。把运行时的 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 等候名单,获得内置捕获的隧道。