氢键MAC Webhook如何知道你没有说谎
每次 GitHub、Stripe 或 Shopify 向您的服务器发送 Webhook 时,它们都会使用 HMAC 对有效载荷进行签名。这里详细说明了其工作原理,以及如何在您的代码中进行验证。
你收到一个声称来自Stripe的POST请求,请求体显示一笔付款已成功。你处理订单,发货——三天后才发现该请求是伪造的。哎,真疼。
这就是为什么每个严肃的Webhook服务提供商都会使用 氢键MAC对它们的请求体进行签名。如果你理解HMAC,你就明白为什么GitHub、Stripe、Shopify、Twilio以及几乎所有现代API都使用它——你也会知道如何在自己的服务器代码中验证这些签名。
HMAC实际上是什么
HMAC代表 基于哈希的消息认证码它回答一个问题:“发送这条消息的人是否知道共享密钥?”
它通过在秘密密钥和消息体的组合上运行一个密码学哈希函数(通常为SHA-256)来工作。输出是一个固定长度的字符串,它会:
- 在消息的任意一个字节发生变化时完全改变 如果消息的任意一个字节发生变化
- 无法在不知道密钥的情况下生成 无法被用来还原密钥或原始消息
- 无法被还原 以揭示密钥或原始消息
公式非常简洁: HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message))你不需要记住内部细节——每种语言都提供标准库实现——但了解其意图是有帮助的:密钥以两种不同方式被混合进哈希中,以防止一类称为“长度扩展攻击”的攻击。
Webhook服务提供商如何使用它
当你注册一个Webhook端点时,提供商会给你一个 签名密钥 ——只有你和他们知道的随机字符串。当事件触发时:
- 提供商将事件负载序列化为JSON(或规范字符串)。
- 它计算
HMAC-SHA256(secret, payload). - 然后将带有签名的请求发送到头部——
X-Hub-Signature-256对于GitHub,Stripe-Signature对于Stripe,以及其他服务。
在你这边,你对原始请求体执行相同的计算并进行比较。如果匹配,负载是可信的。如果不匹配,就丢弃它。
代码中的验证
以下是常见语言中验证的示例。模式在每种语言中都是一致的:计算,使用常数时间函数进行比较。
Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody must be a Buffer or string — NOT parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Python
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
PHP
function verifyWebhook(string $rawBody, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
关键细节:使用原始体
最常见的实现错误是将 解析并重新序列化 的负载传递给HMAC函数,而不是原始字节。键的顺序、空格和Unicode转义都会影响哈希。提供商签名的是它实际通过网络发送的字节——你必须对完全相同的字节进行哈希。
在Express(Node.js)中,这意味着配置体解析器以保留原始缓冲区:
app.use('/webhooks', express.raw({ type: 'application/json' }));
在Django中,使用 request.body 而不是 request.data在Flask中,使用 request.get_data().
为什么不用简单的哈希?
对负载的简单SHA-256哈希无法证明任何东西——任何人都可以计算 SHA256(payload) 而无需密钥。HMAC的密钥才是使其成为 身份验证 消息认证码,而不仅仅是校验和。它回答的是“是谁发送的”而不是“传输过程中是否被损坏”。
为什么不使用非对称签名(如RSA)?
RSA和ECDSA允许接收方在不拥有私钥的情况下验证签名——这对于公开广播(如代码签名)非常有价值。对于Webhook,只有两个实体需要验证签名:你和提供商。共享密钥更简单、更快,且在该模型下同样安全。一些提供商(如Svix、Clerk)提供非对称Webhook签名,用于你无法安全地在服务器端存储密钥的情况。
重放攻击——以及如何阻止它们
有效的HMAC签名可以证明真实性,但不能证明时效性。攻击者可以捕获一个合法的签名请求并稍后重放。Stripe通过在 Stripe-Signature 头部中包含时间戳,并将时间戳与负载体一起哈希来应对这种情况。在你这边,你将任何时间戳超过五分钟的请求拒绝。
如果你正在构建自己的Webhook系统,也应这样做:在签名消息中包含单调递增的随机数或Unix时间戳,并在服务器端拒绝过期请求。
安全比较不是可选项
永远不要使用简单的相等性检查(===, ==)来比较HMAC签名。字符串比较的短路会泄露关于匹配前导字节数量的信息——攻击者可以发起成千上万次请求,逐字节重建预期签名。始终使用常数时间比较:
- Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - PHP:
hash_equals() - Go:
hmac.Equal() - Ruby:
ActiveSupport::SecurityUtils.secure_compare()
整合起来:一个生产级的Webhook处理器
以下是一个使用GitHub的 X-Hub-Signature-256 头部在Node.js中的完整示例:
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
// Keep the body as raw bytes — critical!
app.use('/github/webhook', express.raw({ type: 'application/json' }));
app.post('/github/webhook', (req, res) => {
const sigHeader = req.headers['x-hub-signature-256'];
if (!sigHeader) return res.status(401).send('Missing signature');
const sig = sigHeader.replace('sha256=', '');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Safe to process the event now
console.log('Event type:', req.headers['x-github-event']);
res.sendStatus(200);
});
app.listen(3000);
快速参考:各服务使用情况
| 服务提供商 | 标头 | Algorithm | 签名中是否包含时间戳? |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | 不 |
| Stripe | Stripe-Signature | HMAC-SHA256 | 是的 |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | 不 |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | 不 |
| 松弛 | X-Slack-Signature | HMAC-SHA256 | 是的 |
| Paddle | Paddle-Signature | HMAC-SHA256 | 是的 |
