开始之前

对接 Stripe、支付网关等高安全性 Webhook 时,严格按照文档使用了 HMAC SHA256 算法,但签名校验依然总是失败?这几乎是每个开发者都会踩的坑:90% 的情况是因为你在验签前,提前解析了 JSON,破坏了原始的 Raw Body。

边看边操作在 Webhook 测试 中打开实际工作区
01

停止格式化,获取原始 Raw Body

签名校验极其严格,多一个空格或换行都会导致 Hash 值完全不同。许多 Web 框架(如 Gin、Express)会自动读取并解析 JSON Body。一旦解析过,原始字节流就被破坏了。你必须在任何中间件解析之前,拦截并读取未经任何修饰的 Raw Body 字节。

示例
// Go 语言读取 Raw Body 示例
bodyBytes, err := io.ReadAll(c.Request.Body)
// 必须将 Body 写回,否则后续逻辑无法再次读取
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
02

提取 Header 中的签名与时间戳

安全的 Webhook 会在 HTTP Header 中附带签名字符串(Signature)和时间戳(Timestamp)。仔细阅读文档,通常字段名为 X-Signature 或 Stripe-Signature 。解析出这两个关键值备用。

03

严格按照规则拼接签名字符串

仔细查看官方文档的拼接规则。最常见的格式是 时间戳 + "." + Raw Body 。千万不要用反序列化后的 Struct 再序列化成 JSON 字符串去参与拼接,这必然导致字符顺序或空格不一致而验签失败。

04

使用 HMAC SHA256 计算哈希

使用你在第三方平台后台获取的 Webhook Secret 作为 Key,刚才拼接的字符串作为 Message,进行 HMAC SHA256 计算。最后通常需要将结果转换为十六进制字符串(Hex)或 Base64 编码。

示例
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
05

使用常量时间比较防时序攻击

对比你计算出的签名和 Header 中传来的签名时,千万不要直接用 == 。这会引发“时序攻击”(Timing Attack)。在 Go 中,必须使用 hmac.Equal 来进行安全比对。

示例
if !hmac.Equal([]byte(expectedSignature), []byte(headerSignature)) {
    return errors.New("签名不匹配")
}
06

校验时间窗,拦截重放攻击

即使签名对了,也不代表绝对安全。黑客可以截获完整的旧请求重复发送(重放攻击)。你必须比较 Header 中的时间戳与当前服务器时间,如果误差超过 5 分钟(Tolerance Window),应直接拒绝该请求。

要点总结
  • 必须使用未经任何处理的 Raw Body 参与签名计算
  • 读取 Raw Body 后需将其重新写回 Request 流中
  • 使用 hmac.Equal 比较字符串以防止时序攻击
  • 严格校验时间戳容差(通常为 5 分钟)以防御重放攻击