你把 Webhook 接口暴露出去,怎么确认发过来的请求真的来自 GitHub 而不是攻击者伪造?答案是 HMAC 签名。HMAC 是 API 鉴权的基石,从 Webhook 验签到 JWT 到 AWS 请求都在用。
HMAC = 哈希 + 共享密钥
普通哈希 sha256(msg) 谁都能算——收到消息和哈希后没法判断发送者是谁。HMAC 要求双方共享一个 secret key:
signature = HMAC-SHA256(key, message)
只有知道 key 的人才能算出正确 signature。收方用同一 key 对 message 重算,相等就说明来自可信方且中间没被篡改。
GitHub Webhook 实例
GitHub 推送事件时会在 HTTP header 里带:
X-Hub-Signature-256: sha256=7d38cdd689735b008b3c702edd92eea23791c5f6
接收端验签:
const expected = 'sha256=' +
crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received))) {
return res.status(401).end();
}
两个容易踩的细节:
update(rawBody)必须用原始字节,不能JSON.parse后再JSON.stringify——字段顺序、空格、数字精度可能变化,哈希就不一样了。Express 里要用express.raw({type: 'application/json'}),别用默认的express.json()。- 用
timingSafeEqual,不要用===。
为什么不能直接 SHA256(key + message)
看起来 sha256(key + message) 也能校验身份,但它有长度扩展攻击漏洞。
SHA-256 等 Merkle–Damgård 结构的哈希会把当前内部状态作为下一轮输入。攻击者已知 h = sha256(key + data) 和 len(key + data) 后,不用知道 key 就能计算:
h' = sha256(key + data + padding + extra)
相当于白嫖一个合法签名。Flickr API 2009 年就被这样打过。
HMAC 通过双轮结构堵死这个漏洞:
HMAC(key, msg) = H((key ⊕ opad) ‖ H((key ⊕ ipad) ‖ msg))
两个不同的常量 pad(ipad = 0x36…、opad = 0x5C…)+ 两次哈希嵌套,攻击者无法从外层哈希反推内层状态。
SHA-3 和 BLAKE2/3 没有长度扩展漏洞,理论上可以直接 sha3_256(key + msg),但工程上仍推荐 HMAC——协议层一致性比”少一层包装”重要。
四个常见用途
1. Webhook 验签
GitHub、Stripe、Shopify、飞书、微信公众号、钉钉,全是 HMAC。每家 header 名字不同,逻辑一模一样。
2. API 请求签名(AWS SigV4、微信支付 v3)
每个请求拼一个规范化字符串再签:
signature = HMAC(secret,
method + '\n' +
path + '\n' +
query + '\n' +
timestamp + '\n' +
body_sha256)
带在 header 里传。服务端同样拼 + 重算 + 对比。
3. JWT(HS256)
HS256 的 JWT 就是 HMAC-SHA256。header 和 payload base64 拼接当 message:
signature = HMAC-SHA256(secret,
base64url(header) + '.' + base64url(payload))
注意:JWT 是签名,不是加密——payload 任何人都能 base64 解出来。别把密码、手机号、身份证明文放 payload。
4. 令牌哈希存储
服务端把 API Token 写数据库前用 HMAC 处理,存的是 HMAC(server_key, token)。泄库时黑客拿到的是哈希,没有 server_key 无法反推回原 token。
典型坑清单
- 密钥硬编码在前端:HMAC 的前提是 key 不外泄,放前端 = 废了,签名和身份证明等于作废
- 拼 message 忘了分隔符:
HMAC(k, user + action)→("ali", "ce_pay")和("alice", "_pay")算出同一签名。用不会出现在字段值里的分隔符(\n/|)或直接 JSON 规范化 - 用普通
==比签名:时序攻击漏洞,必须 timing-safe 对比 - 没带时间戳:重放攻击,攻击者截一次包能无限复用
- Base64 / Hex 混用:对方期望 hex、你发 base64,永远验不过。在协议里写死
- 签名覆盖不全:只签 body 不签 path / query,攻击者改 URL 仍能通过
- 用弱哈希:HMAC-MD5 / HMAC-SHA1 在实践中仍然安全(HMAC 不依赖底层哈希抗碰撞),但新项目没理由不用 SHA-256 起步
小结
- 要签名用 HMAC,不要自己拼
hash(key + msg) - key ≥ 32 字节,来自密码学 RNG
- 对比用
timingSafeEqual - 带 timestamp + nonce 防重放
- 编码格式(hex / base64)在协议里明确约定
SHA-256 是原材料,HMAC 是把它变成可用签名的必要加工。API 鉴权必备。