Webhook 签名验证失败?定位你碰上的那个原因
webhook signature verification failed 只意味着一件事:你的代码算出来的摘要,和请求头里带的那一串不相等。这就是它全部的信息量。它不代表权限不足,不代表签名过期,也几乎从不是服务商 SDK 的 bug。差异出在字节上:服务商拿去做哈希的那串字节,和你拿去做哈希的那串字节,对不上。
结果由四个输入决定:签的是哪串字节、用的是哪串密钥字节、跑的是哪个哈希算法、比较时用的是哪种文本编码。其中任何一个错了,失败的样子都一模一样。错误消息不会透露是哪一个,所以要做的是缩小输入空间,而不是把那句话再读几遍。
先选一个入口分支:
签名对不上?三个分支:
├─ 你的框架在你看到之前就把 JSON 解析掉了? → 第 3 节
├─ 请求头的值带前缀,或者看着像 base64? → 第 4 节
└─ 服务商的请求头里含时间戳? → 第 2 节
每一节的结尾都有一个能拿你自己的 payload 直接跑一遍的检查。
1. 签名不匹配到底告诉了你什么
验签就是两串字节的比较,中文文档里也常写成签名验证,指的是同一件事。它失败时,出错的通常是四件事之一,而这四件事彼此独立。
第一个是被签名的字节。服务商哈希的是一段特定的字节序列,可能只是请求 body,也可能是一个时间戳粘在 body 前面。如果你的框架已经把 JSON 解析掉、递给你一个对象,那串字节就没了,而且无法可靠地还原。这是第 3 节的内容,也是最常见的原因,而且遥遥领先。
第二个是密钥的字节。同一个密钥字符串,可以按 UTF-8 文本读,也可以按 hex 或 base64 读,每种读法给出的密钥都不同。配置加载器顺手留下的一个多余换行,效果也一样。这个维度里还藏着第二种失败:密钥可能整个就是另一把,而不是对的那把被读错了方式,那属于第 6 节。
第三个是比较时用的编码。SHA-256 的摘要是 32 个原始字节,hex 和 base64 只是把同一串字节写成文本的两种方式,看上去永远不像。拿一种去比另一种,就算底层字节完全一致,也会得到一个永久性的 hmac signature mismatch。
第四个是跑的哪个哈希算法。多数服务商用 SHA-256,而且写在文档里,所以这个维度通常不花你什么工夫。例外是 GitHub:每一次投递都同时带 X-Hub-Signature(HMAC-SHA1)和 X-Hub-Signature-256(HMAC-SHA256),GitHub 自己的文档说 SHA-1 那个头「仅为兼容旧版本而保留」,同时推荐用 256 那个。读错了的话,字节还没开始比,长度就先露了馅。第 2 节那个 body 用同一把密钥按 SHA-1 签出来是 sha1=ba2954d180839d8170b08b32cd38483775aaae96,40 个 hex 字符,而它的 SHA-256 摘要是 64 个。
排查时把这四件事分开。隔离某一个维度最快的办法,是在应用之外用你自己控制的输入算一遍摘要:把 body 和密钥粘进 HMAC 生成器,看它给出什么。它完全在你的浏览器里运行,密钥不会离开这个页面,所以生产环境的签名密钥也可以放心粘进去。HMAC 跑的就是和普通 SHA-256 哈希 一样的原语,只是多带了一把你的密钥。所以只要你能手工复现服务商给的那个值,密码学这层就没问题,bug 在你的请求处理链路里。
2. 四家主流服务商到底签的是什么
让大多数集成翻车的假设是:所有服务商都只签请求 body,别的什么都不加。四家最大的里有两家不是这样。下面是每一家实际哈希的内容:
| 服务商 | 请求头 | 被签名的字符串 | 编码 | 值前缀 | 密钥 | 时间戳容差 |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | 端点签名密钥(whsec_ 前缀) | 5 分钟(300 秒) |
| GitHub | X-Hub-Signature-256 | rawBody(无前缀) | hex | sha256= | webhook 密钥 token | 无(不发时间戳) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | signing secret(签名密钥) | 5 分钟 |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | 无 | 应用的 client secret(不是另外那个 webhook 密钥) | 无 |
这四家刚好覆盖了三个互相正交的轴。被签名的字符串要么只有 body,要么是时间戳拼接,而且连分隔符都不一样:Stripe 用 .,Slack 用 :。编码上三家是 hex,一家是 base64。密钥来源上三家用专门的 webhook 凭据,Shopify 用应用的 client secret。最后这个细节是出错最多的地方,因为后台界面里有一个标着「webhook」的字段,而它并不是你要的那个。
把差异落到具体值上。同一个 body、同一把密钥,用四种方式签出来:
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| 形态 | 值 |
|---|---|
| GitHub 风格 | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| Shopify 风格 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| Stripe 风格 | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| Slack 风格 | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
前两行要连起来看,因为它们是同一个 32 字节摘要的两种写法。64 个 hex 字符,或者算上填充的 44 个 base64 字符。两串字符串没有任何一处让人觉得它们相等,所以跨编码比较产生的不匹配,能扛过你能想到的每一轮「可是密钥是对的」自查。
后两行说明的是另一半。body 一样、密钥一样、算法一样,两个摘要却都和 GitHub 那个不像,因为被哈希的字符串现在以时间戳开头。绝大多数 stripe webhook signature verification failed 的报告都落在这一行:代码只哈希了 body,从来没有在前面拼上 t 的值和那个点。在 HMAC 生成器 里只改 message 字段、切换输出格式,就能把这四个值全部复现出来,机制也就不再抽象了。
时间戳这一列有个很实际的后果:Stripe 和 Slack 的摘要只在几分钟内有效,所以你没法今天抓一个签名、明天拿去测试里重放。GitHub 和 Shopify 的签名永久稳定,调试起来容易得多,代价是防重放得你自己考虑。
3. 原始 body(raw body)问题
多数 webhook signature verification failed 的报告,最后都落回同一个原因:handler 拿到的已经不是服务商签的那串字节。
你的框架已经把字节毁掉了
Web 框架存在的意义之一就是替你把解析做掉。而正是这份便利搞坏了签名验证:等你的 handler 跑起来时,原始字节已经不在了。
express.json() 读掉请求流、解析它,然后把 req.body 换成一个 JavaScript 对象。流已被消费,无法二次读取。FastAPI 里只要你声明了 Pydantic 模型或者 dict 类型的 body 参数,框架就会在进入你的函数之前完成读取和解析。Rails 通过一个跑在 controller action 之前的中间件,用 JSON body 填充 params。Spring 的 Jackson 转换器会把 body 变成你的 DTO 类,而底层的 HttpServletRequest 输入流默认只能读一次。
这里没有任何一处是 bug,它们都在按配置做该做的事。问题在于签名覆盖的是字节,对象不是字节,而把对象重新变回字节,和服务商当初做的那个操作并不是同一件事。
重新序列化有时确实能对上,坑就在这里
常见的说法是重新序列化会改变字节。这句话不完整,而缺掉的那一半正是这类故障难诊断的原因:有时候它一个字节都不改。
下面是 JSON.stringify(JSON.parse(body)) === body 在各种 payload 形态上的实测结果:
| payload 形态 | round-trip 后的字节 | 变化 |
|---|---|---|
{"id":42,"event":"user.created"} | 完全相同 | 无变化,所以本地测试能过 |
{"amount":1.0} | 变了 | → {"amount":1} |
{"n":1e3} | 变了 | → {"n":1000} |
{"id":12345678901234567890} | 变了 | → {"id":12345678901234567000}(精度丢失) |
{"name":"caf\u00e9"} | 变了 | → {"name":"café"}(6 字节变成 2 字节) |
{"a":1}\n | 变了 | 尾随换行被吞掉 |
{ "a" : 1 } | 变了 | 内部空格被吞掉 |
{"v":-0.0} | 变了 | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | 变了 | → {"p":0.1} |
看第一行。一个只有整数和短 ASCII 字符串的扁平对象,round-trip 后字节完全一致,所以「先解析再重新序列化」的验证代码,能通过你针对这类 fixture 写的每一个测试。然后你上线了,第一个带着 1.0 这种金额、超过 2^53 的 ID、或者带重音符号的客户名的 payload 就失败了。不是全部失败,只有这些失败。
这就是「本地正常,生产偶发 401」背后的机制,而它比一直失败糟糕得多。一直失败的验证代码一小时内就会被修掉。只在 3% 的事件上失败的那种,团队会先怪服务商,再靠重试兜住,然后升级工单,最后将就着用上好几个星期。如果你的失败率严格落在 0% 和 100% 之间,先来查这张表。
键顺序是大家最先想到的原因,实际上却最不可能,因为 JSON.parse 对字符串键保留插入顺序。真正的元凶是数字和空白字符。
各个框架里怎么拿到原始 body
Express,把针对单条路由的解析器注册在全局 JSON 解析器之前:
const express = require('express');
const crypto = require('crypto');
const app = express();
// 这条路由必须注册在 app.use(express.json()) 之前。
// body-parser 会把请求标记为已解析,之后再调 raw() 只会静默得到 {}。
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // 是 Buffer,不是对象
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // 直接哈希 Buffer,不要 toString()
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // 其余路由照旧拿到解析好的 JSON
app.listen(3000);
如果没法调整中间件顺序,就在解析过程中留一份拷贝:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI。Starlette 会缓存 body,所以即便在一个同时接收解析后模型的 handler 里,await request.body() 也照样返回原始字节:
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # 字节,与收到时一模一样
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails 这边,request.raw_post 把未经解析的 body 以字符串形式给你:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go 这边你自己读 body,而且要记住读完之后它就空了:
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// 从 raw 反序列化,绝对不要用 r.Body,它里面已经没有字节了。
w.WriteHeader(http.StatusOK)
}
Spring 这边,参数写成 byte[] 就完全跳过 Jackson:
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
如果校验必须放在 filter 里做、又改不了 controller 的签名,替代方案是 ContentCachingRequestWrapper。它自己也带一个坑:getContentAsByteArray() 只在下游有人读过流之后才返回字节,所以在 chain.doFilter(...) 之前调用它,拿到的是空数组。
4. 编码不匹配:hex、base64,以及密钥本身
你的摘要和请求头里的值之间,隔着三个各自独立的编码决策,其中任何一个都足以单独把比较搞崩。
一个是摘要编码。HMAC-SHA256 的输出是 32 字节,写成小写 hex 是 64 个字符,写成标准 base64 是 44 个字符(含 = 填充)。第 2 节那两行是最干净的演示:
| 编码 | 字符数 | 同一串 32 字节写成 |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
面对一个不熟悉的请求头,有个快速判断法:值是 64 个 0-9a-f 字符就是 hex;是 44 个字符且以 = 结尾,或者含 +、/、大写字母,就是 base64。想确认而不是猜,把 base64 值丢进 Base64 解码工具,看它是不是解出 32 字节。如果是,两串字符串描述的就是同一个摘要,你比的是文本格式,不是签名。
另一个是值前缀。GitHub 会在 hex 前面发一个 sha256=,Slack 发 v0=,Stripe 把所有东西包进一串逗号分隔的 key=value。这些字符都不属于摘要本身,所以要么从请求头里剥掉前缀,要么给自己的值也加上。两件都没做,是一个本身写对的实现报出 hmac signature mismatch 的头号原因;而在 Node 里它甚至不会报不匹配,原因见第 7 节。
最后是密钥编码。密钥也是字节,同一串字符按 UTF-8、hex、base64 读,会得到三把不同的密钥。像 whsec_... 这样直接给你一个文本 token 的服务商要的是 UTF-8,但很多内部系统下发的是 base64 或 hex 密钥,必须先解码再签。这类故障和 JWT 那边的同一个问题形态完全一致,JWT 报 invalid signature:逐个排查真正的原因 里讲得更细,包括怎么判断手上这把密钥是 base64 还是纯文本。
5. 时间戳、容差与重放窗口
摘要算得完全对上,请求照样可能被拒。带时间戳的服务商指望你去校验它,而一个过期的时间戳意味着:签名是有效的,但你仍然必须拒掉它。
| 服务商 | 时间戳在哪里 | 窗口 |
|---|---|---|
| Stripe | Stripe-Signature 里的 t= | 5 分钟(300 秒) |
| Slack | X-Slack-Request-Timestamp 请求头 | 5 分钟 |
| GitHub | 不发送 | 不适用 |
| Shopify | 不发送 | 不适用 |
窗口设错,两个方向都疼。设得太宽,抓到的请求在你允许的整段时间里都能重放,校验时间戳的意义基本就没了。设得太紧,寻常的时钟漂移就开始拒掉真实投递。两家服务商都选了 5 分钟,照抄是个稳妥的默认值。
在放宽容差之前,先查时钟。容器镜像里不跑 NTP,从快照恢复的虚拟机可能比真实时间慢好几分钟,而日志里一点提示都没有。持续漂移的主机会造成先偶发、后全面的失败,看起来像代码回归,其实不是。
另一个时钟 bug 是单位不一致。表里每一家发的都是 epoch 秒。拿它去和 JavaScript 的 Date.now() 这类毫秒值相比,差值大约是真实时长的一千倍,于是每个事件都落在任何合理窗口之外。症状是摘要本身对得上,容差检查却拒掉 100% 的投递。分不清手上是哪个单位时,位数就是线索,epoch 秒与毫秒的区别 讲了这些换算以及周边的时区坑。
构造被签名字符串时,用请求头里那个原样的时间戳字符串,不要用解析后重新格式化的数字。把 1700000000 解析成浮点再打印回来,可能得到 1700000000.0,这是另一串字节。
6. 密钥用错,以及会轮换的密钥
再往编码里钻之前,先排掉最朴素的那个原因:这把密钥可能根本不是该用的那把。Stripe 的文档写得很直白,「Stripe 会为每个端点生成一把唯一的密钥」,而且如果你把同一个 URL 同时挂在测试密钥和正式密钥下,「两边的密钥是不一样的」。同一个错误由此有三个版本。
测试模式和正式模式各持一把密钥,所以在后台处于测试模式时复制走的值,会让每一次正式投递都失败。每个端点也各持一把,文档还补了一句:「如果你使用多个端点,就必须为每个想验签的端点各取一把密钥」。把两个端点指向同一个 handler、环境变量里只放一把密钥,你就会有一半流量失败。还有 stripe listen,它为 CLI 的本地转发打印出一把签名密钥,那是一个和后台里注册的任何端点都不同的端点,两者不能互换。
这几种从外面看都不像编码 bug。摘要格式规整,比较逻辑正确,环境变量里那个值也确实是一把真的 Stripe 密钥,只不过不是签了这次投递的那把。
轮换是同一个维度在你脚下移动。它最不像编码问题,也最常被误诊成代码 bug。你的代码一行没改,昨天验证还是好的,现在一部分事件开始失败。
重叠窗口是有意设计的。你轮换之后,Stripe 会让旧的端点密钥继续有效最多 24 小时,这期间 Stripe-Signature 请求头里每一把生效的密钥各带一个 v1 签名。Shopify 反过来:轮换之后最多要一个小时它才开始用新密钥算摘要,所以这段时间里你需要的是旧的那把。
会把代码搞坏的是 Stripe 这种行为,因为请求头看上去只装了一个签名。按 , 切开、取找到的第一个 v1,在只有一个的时候一直没问题,等到有两个时,你能对上的比例大约是一半,取决于哪把密钥签了哪个事件。要遍历全部:
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // 保留原始字符串
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
除了那个循环,这段里还有两处细节要紧。时间戳是以它到达时的字符串形态进入被签名 payload 的;body 是按字节拼接的,而不是走模板插值,因为插值会先把它按 UTF-8 解码。
你自己这边轮换时也是同一个形状:在重叠期内同时接受新旧两把密钥,然后把旧的去掉。轮换到的新密钥必须有足够熵,所以要生成而不是手打,比如用 签名密钥生成器 取一个 256 位随机值。
7. 比较签名时不泄露时序
两个摘要都到手之后,怎么比是一个安全决策。字符串相等一发现有字节不同就立刻返回,所以耗时会暴露开头有多少字节是对的。能大量提交请求的攻击者靠这个一个字节一个字节地还原出有效签名。走公网慢且噪声大,在局域网里则完全可行。
每种运行时都自带定时比较(constant-time compare)函数:
| 语言 | 定时比较函数 | 长度不同时 |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | 抛异常 |
| Python | hmac.compare_digest(a, b) | 返回 False |
| Go | hmac.Equal(a, b) | 返回 false |
| PHP | hash_equals($known, $user) | 返回 false |
| Ruby | OpenSSL.secure_compare(a, b) | 返回 false |
最后一列是一整类迷惑事故的源头。Node 是那个例外,而且它失败得并不客气:
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
算一下它什么时候会触发。hex 形式的 SHA-256 摘要是 64 个字符。X-Hub-Signature-256 里的值是 71 个,因为 sha256= 占 7 个字符。忘了剥前缀,两个 buffer 长度就不一样,于是 timingSafeEqual 抛异常而不是返回 false。没捕获的话,这个异常会从你的 handler 里冒出来,被 Express 变成一个 500。
再想想你看到的是什么。你等的是一个 webhook 401 unauthorized 响应,拿到的却是服务器错误,于是你去翻 handler、翻数据库调用、翻事件分发。真正的 bug 在比较那行的上一行。拿 64 字符的 hex 摘要去比 44 字符的 base64 摘要,会因为同样的原因抛异常,也就是说在 Node 里编码不匹配同样表现为 500,而不是一次干净的拒绝。
修法是自己先查长度,然后返回 false:
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // 在调用之前先挡一层
return crypto.timingSafeEqual(a, b);
}
泄露长度是无害的:摘要长度由算法固定,本来就是公开信息。绝不能泄露的是哪一段前缀匹配上了。HMAC 生成器 的校验(Verify)标签页不提前返回,而是把长度差异一起折进定时比较的那个累加器,所以长度不一致时它返回的是普通的 false 而不是异常,你可以直接拿请求头的值和自己算出的摘要对一下,不用为此写一段一次性代码。
8. 当传输层动了你的字节
被签名字符串、原始 body、各种编码、时钟、轮换都排除掉了。剩下的可能是:到达你进程的字节,不是从服务商发出去的那串字节。
压缩。 服务商或代理可能带 Content-Encoding: gzip 把 body 压缩后发出。签名覆盖的是未压缩的 payload,所以必须解压之后再哈希。有些框架会透明解压,有些直接把压缩后的字节递给你,日志里的 body 看起来像一堆二进制乱码就是破绽。
分块传输。 用 Transfer-Encoding: chunked 时没有 Content-Length,靠这个头来定读缓冲区大小的代码会把 body 截断。截断 body 的摘要是合法的废话:它永远对不上,而且哪里都看不出不对。
代理与 WAF。 任何会读取并重写 body 的层都可能改动它。AWS API Gateway 可能在 body 到达 Lambda 之前对它做 base64 编码,所以要先解码再哈希。应用负载均衡器、服务网格和 Web 应用防火墙也都可能对 payload 做规范化或重新编码。验证方法是把你 handler 看到的字节长度,和服务商发来的 Content-Length 对一下。
字符编码与 BOM。 payload 里可能含非 ASCII 字符,GitHub 文档明确要求把 payload 按 UTF-8 处理。用错误的字符集把 body 解码成字符串再编回去,会毁掉其中每一个多字节字符。编辑器或序列化器好心在前面加上的 UTF-8 字节序标记 EF BB BF,会凭空多出三个从未被签名的字节。
行尾与多余空白。 经过文本模式文件环节的 body,可能带着被改写成 CRLF 的 LF 到达。也要去读服务商关于被签名字符串的确切说明:有些会自己追加一个字符,Typeform 就是有明确文档记载的例子,尾随换行是被哈希内容的一部分。服务商文档里提到任何额外字符,都要照字面理解。
9. 一套可复用的排查流程
按顺序跑。每一步要么找到 bug,要么砍掉一个分支,能早停就是目的。
- 在任何中间件运行之前记录原始字节。 从请求生命周期里你能碰到的最早位置,把 body 写进文件,或者记下它的字节长度加 SHA-256。仅凭长度就能解决相当多的情况:比预期大 1 是尾随换行,大 3 是 BOM。
- 手工算一遍摘要。 把那串确切的字节和你的密钥粘进 HMAC 生成器,选 SHA-256,把输出格式设成和请求头一致。这是单步价值最高的一步,因为它把问题干净地劈成两半。
- 把手算的值和请求头对比。 相等说明字节和密钥都对,bug 在你代码路径里的某处,那就去读你的比较逻辑。不相等说明某个输入是错的,继续往下。
- 拿第 2 节的表核对被签名的字符串。 这家服务商会不会在前面拼时间戳?用哪个分隔符?在工具里加上前缀重算一次。
- 换摘要编码。 分别按 hex 和 base64 重算,两个都和请求头比一遍。请求头的值是 44 个字符、末尾带
=,那它就是 base64,不管你的代码当初假设的是什么。 - 换密钥编码。 把密钥依次当作文本、hex、base64 试一遍。三者之一通常能对上,而这就告诉你服务商要的是哪一种。
- 查时钟和轮换状态。 拿你服务器的时间和一个可信来源对一下,确认你处理的是 epoch 秒,再去服务商后台看最近 24 小时有没有发生过轮换。
两个习惯能让这个循环快很多。第一,抓一个失败的 payload 下来离线折腾,别等下一次投递。第二,用固定签名把这个抓下来的 body 重放到你的端点,这样每次尝试的输入都不变。cURL 命令生成器 会用确切的请求头和从文件读入的 body 拼出请求,保证每次跑的字节一致。能随时复现失败,才能把一份偶发的 webhook signature verification failed 报告变成五分钟就修好的事。
如果还是得开支持工单,附上你哈希的 body 的字节长度、请求头值的原文、你构造被签名字符串的方式,以及摘要编码。永远不要把密钥本身附上。
FAQ
为什么我的 webhook 签名本地正常、生产环境却失败?
你的测试 payload 大概能原封不动地通过一次 JSON round-trip,所以重新序列化它没有副作用。真实 payload 里有浮点数、大整数、Unicode 转义或多余空白,这些确实会改变字节。签原始 body,不要签重新序列化的副本;第 3 节的表列出了哪些形态会坏。
比较签名时要不要带上 sha256= 前缀?
要么剥掉它,要么给自己的值也加上,让两串字符串完全一致。你算出的 hex 摘要是 64 个字符,带前缀的请求头值是 71 个。有些比较函数在长度不一致时返回 false,而 Node 的 timingSafeEqual 是抛异常而不是返回 false。
框架已经解析过 JSON 之后,还能验签吗?
不可靠。只有当 payload 里没有浮点数、没有超过 2^53 的整数、没有 Unicode 转义、也没有多余空白时,重新序列化才能复现原始字节。其中任何一项一出现,摘要就变了,于是测试里验证通过,生产里一部分事件失败。
同一个 payload,为什么 Stripe 和 GitHub 算出的签名不一样?
因为它们哈希的字符串不同。GitHub 只签原始 body。Stripe 签的是时间戳、一个字面的 .,然后是 body,所以同一个 payload 在两个不同时刻投递会得到两个不同摘要。Slack 在前面拼的是 v0: 和它自己的时间戳。算法相同,输入不同。
时间戳容差应该设多久?
Stripe 和 Slack 用的都是 5 分钟,照抄是个合理的默认值。窗口更短,服务器时钟一漂移就会拒掉合法投递。窗口更长,抓到的请求可被重放的时间段就更宽。放宽容差之前,先用 NTP 把时钟同步好。
长度不同时 timingSafeEqual 会返回 false 吗?
不会。Node 抛的是 RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length。没捕获就会变成 500 而不是 401,把你引去调试 handler,而不是比较那行的上一行。自己先比长度,然后自己返回 false。
服务商已经轮换过密钥了,为什么还有一些 webhook 失败?
轮换窗口是重叠的。Stripe 让旧密钥继续有效最多 24 小时,并且每一把生效的密钥各发一个 v1 签名,所以只读第一个 v1 的代码会在大约一半的事件上失败。Shopify 则可能要一个小时才开始用新密钥。
结语
验证就是字节比较,所以 webhook signature verification failed 最终总能归结为字节上的分歧,而不是密码学层面的任何东西。排查时按维度拆开处理:
- 在任何解析器碰到原始 body 之前就把它抓下来。哈希重新序列化的对象,对上的频率足以让你的测试通过,却不足以让线上正常工作。
- 弄清密钥该按哪种方式读。同一把密钥按文本、hex、base64 读,给出三把不同的密钥。
- 对齐摘要编码。Hex 是 64 个字符,base64 是 44 个,两者描述的是同一串 32 字节。
- 剩下的原因大致按这个可能性顺序排:时间戳前缀、值前缀、容差窗口、轮换重叠期、传输层。
- 比较时先挡一层长度检查,再用你的运行时自带的定时比较函数。
想要一个可信的参照值去比对时,在应用之外把它算出来:把 body 和密钥粘进 HMAC 生成器,让它告诉你哪一边错了。