必须用 io.ReadAll(r.Body) 一次性读原始字节,因 GitHub、Stripe 等平台签名验证依赖未修改的原始请求体;json.Decode 会消耗 Body 导致后续读取为空,造成签名验签失败。

为什么必须用 io.ReadAll(r.Body) 一次性读原始字节
因为 GitHub、Stripe、PayPal 等平台的签名验证,全部依赖未解析、未修改的原始请求体。而 json.NewDecoder(r.Body).Decode() 会内部消费 r.Body 流,再读就返回 EOF —— 你算的签名其实是空字符串的哈希。
常见错误现象:hmac.Equal 总是返回 false,但手动打印 header 签名和你算的值看起来“一样”;根源就是 body 已被提前读走。
- 正确顺序:先
payload, err := io.ReadAll(r.Body),再用payload做验签,最后用同一份payload做json.Unmarshal - 必须加长度限制:
io.LimitReader(r.Body, 1024*1024),防恶意大包耗尽内存 - 别在中间件里提前
ParseForm或Decode,否则 handler 里再读就空了
hmac.Equal 验签时三个硬性条件不能漏
验签不是“算出来一样就行”,它卡在编码格式、拼接规则、时序防护三重陷阱上。
- GitHub 的
X-Hub-Signature-256要去掉sha256=前缀后 hex 解码;Stripe 的Stripe-Signature是逗号分隔(t=xxx,v1=xxx),得提取v1段再比 - PayPal 和微信支付要求拼接
timestamp + payload,少一个换行、多一个空格,签名就失效 - 必须用
hmac.Equal,绝对不要用==—— 否则暴露时序侧信道,可被用于签名爆破
HTTP handler 里只做三件事:验签、回 200、扔进队列
第三方 Webhook 服务(Shopify、GitLab、Alertmanager)普遍要求响应时间 ≤ 3–5 秒。任何 DB 查询、外部 HTTP 调用、复杂 JSON 解析,都必须移出 handler。
立即学习“go语言免费学习笔记(深入)”;
- 响应必须严格:
w.WriteHeader(http.StatusOK),不写任何body,不设额外header(比如Content-Type) - 业务逻辑异步化:用带 buffer 的
chan或 worker pool 控制并发,避免 goroutine 泄漏 - 幂等性检查不能靠 handler 补——要在异步任务里查
event_id或idempotency_key,否则上游重试会导致重复扣款
不同平台签名机制差异极大,不能套一套逻辑
GitHub 和 GitLab 的签名机制完全不同,混用一套逻辑会直接导致 400 或超时。
- GitHub:读
X-Hub-Signature-256,hex 解码后与原始payload+ secret 算 HMAC-SHA256,用hmac.Equal比对 - GitLab:直比
X-Gitlab-Token头和本地配置的 token 字符串,大小写敏感、前后空格不能strings.TrimSpace - Stripe / PayPal / 微信支付:每家拼接规则、时间戳字段名、哈希算法都不同,建议用
payclaw这类专用库封装适配器,别手搓
最常被忽略的是:验签失败时,仍要返回 200(某些平台要求)或明确的 401/403,但绝不能 panic 或返回 5xx —— 否则触发上游无限重试。


















