2026年8月17日
HMAC 详解:密钥哈希如何保护你的 Webhook
接入支付平台或者代码托管服务时,你一定见过 X-Signature、X-Hub-Signature-256 这类请求头。它们几乎都是 HMAC。这篇指南讲清楚三件事:HMAC 算的是什么、为什么单纯的哈希做不了身份认证、以及校验 Webhook 签名的正确姿势——包括那些让签名校验形同虚设的常见错误。
文中所有示例都可以配合本站的 HMAC 生成器 验证:它完全在浏览器里计算,粘贴真实的 Webhook 报文和测试密钥也不会有任何数据离开你的机器。
HMAC 解决什么问题#
SHA-256 这类密码学哈希回答的是一个问题:*这段字节变没变?*喂给它一份文档得到一个摘要;再喂同一份文档,摘要不变;改动一个比特,摘要面目全非。
这在「信道可信」的前提下够用——Linux 发行版发布 SHA-256 校验和,你下载后自己对一遍即可。但如果摘要和报文走的是同一条信道,哈希就什么都证明不了:攻击者既然能篡改负载,就能把篡改后的负载重新哈希一遍、连同摘要一起替换。摘要跟着报文走,两者一起改毫无难度。
Webhook 需要的是另一个性质:摘要必须只有持有共享密钥的人才能算出来。这样一来,中间人随便改写报文都没用——没有密钥就伪造不出匹配的摘要。这个构造就是 HMAC(Hash-based Message Authentication Code,基于哈希的消息认证码),由 RFC 2104 标准化,SHA-2 系列的实现细节见 RFC 4231。
两个问题由此分得很清楚:
- 裸哈希回答「这段字节流变过吗?」
- HMAC 回答「这段字节流变过吗,并且它是不是持有我方密钥的人发来的?」
HMAC 内部是怎么算的#
安全地使用 HMAC 不需要数学推导,但一段直观描述能帮你判断密钥长度和哈希选型。HMAC 会让底层哈希跑两轮,用固定的填充块把密钥混进每一轮:
- 从密钥派生两个 64 字节的块:内层填充(
k XOR 0x36…)和外层填充(k XOR 0x5c…)。短于块大小的密钥补零;过长的密钥先哈希压缩。 - 用 SHA-256 之类的哈希计算
innerPad || message。 - 再计算
outerPad || (第 2 步结果)。
输出长度与底层哈希一致:SHA-256 是 32 字节,SHA-512 是 64 字节。密钥被折进两轮计算,知道消息内容也无法走捷径推算摘要——只能对密钥本身暴力破解。双轮结构也是「长度扩展攻击」对 HMAC 无效的原因(那种攻击打破的是朴素的 hash(key || message) 写法)。
由此得到三条实践结论:
- **密钥熵是全部关键。**256 位随机密钥的 HMAC-SHA-256 无法有效暴力破解;密钥是
"secret"的同一个 HMAC 几秒内就被字典攻破。签名密钥务必用密码学安全随机源生成——32 个随机字节、十六进制编码,是稳妥的默认值。 - **哈希选择不是短板。**SHA-1 作为数字签名原语已经出局,但 HMAC-SHA-1 并无实际攻击。不过新系统一律选 SHA-256:零成本,且与主流服务商一致。
- 永远不要手工拼装。
hash(key + message)不是 HMAC,会被长度扩展攻击打破。各标准库都有现成实现:Node 的crypto.createHmac、Python 的hmac.new、浏览器里的 Web Crypto。
逐步校验一个 Webhook 签名#
各家的请求头名字不同——Stripe 用 Stripe-Signature,GitHub 用 X-Hub-Signature-256,Slack 用 X-Slack-Signature——但校验流程几乎一样。以一个发送 X-Signature: sha256=<hex> 的通用服务为例:
- **按字节读取原始请求体。**不是解析后的 JSON,而是发送过来的原始字节。解析再重新序列化会改变键顺序和空白,摘要必然对不上。先缓存原始 body,再做 JSON 解析。
- 用共享密钥对原始 body 重算 HMAC,哈希算法与服务商一致(除非另有说明,就是 SHA-256)。
- **常数时间比较摘要。**普通
==字符串比较在第一个不同的字符处短路,会泄露前多少字节相同。高频接口上的时序泄露可以被拼成完整的摘要伪造。用crypto.timingSafeEqual(Node)、hmac.compare_digest(Python)或等价物。 - **不匹配就拒绝。**返回 2xx 之外的错误码,绝不重定向。记录失败日志,但不要记录密钥。
一个最小的 Node.js 处理器:
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
const expected =
"sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body) // 原始字节,不是解析后的 JSON
.digest("hex");
const got = req.get("x-signature") ?? "";
const ok = got.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.status(400).end("bad signature");
// 到这里才解析 JSON、处理事件
res.status(204).end();
});
实际对接时还会遇到两个服务商侧的变体:
- **带时间戳的签名(Stripe、Slack)。**签名串包含时间戳和报文,例如
t=1690000000,v1=…。校验时对"${t}.${rawBody}"计算 HMAC,并且拒绝时间戳早于约 5 分钟的请求。这样才能防重放:被截获的合法签名请求无法在几小时后重发。 - 多个签名值。请求头可能携带多个
v1=(服务商轮换密钥时不中断服务)。只要任意一个值匹配当前或上一任密钥,就接受该请求。
实例演算#
RFC 4231 的测试向量里有一对经典输入:密钥 Jefe、消息 what do ya want for nothing?。HMAC-SHA-256 的十六进制摘要:
5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
同样的输入换成 HMAC-SHA-512,得到 64 字节摘要:
164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737
两组结果都可以在 HMAC 工具里复现:载入示例、切换 SHA-256 与 SHA-512、点生成。如果你自己的库对这组输入算出了不同结果,问题几乎一定出在编码环节——确认密钥和消息先做了 UTF-8 编码,十六进制输出的是原始摘要字节而不是叠了一层 base64。
让校验形同虚设的常见错误#
- **先解析后校验。**永远先验签再解析 JSON。解析后重序列化的 body 永远验不过,这会逼着开发者「临时」关掉校验——最坏的结局。
- **非常数时间比较。**上一节说过;这是网上 Webhook 示例代码里被复制最多的安全缺陷。
- **多环境共用一个密钥。**测试和生产用不同的密钥,并利用服务商提供的双签名窗口做轮换。
- **把 Webhook 当授权用。**合法签名只证明消息来自服务商——不证明消息描述的事情仍然成立。退款这类高价值事件,先通过服务商 API 回查状态再执行。
- **把密钥放进前端代码。**签名密钥属于服务端。如果校验必须在浏览器环境完成,那就该有一个你自己控制的后端接口来做。
常见问题#
HMAC 是加密吗?#
不是。HMAC 只做认证——证明完整性和来源。消息本身仍是明文。如果还需要机密性,另行加密(例如 AES-GCM),或使用同时提供两者的认证加密模式。
用 HMAC 还是 JWT?#
两者解决的问题不同。JWT 内部往往就包含一个 HMAC(HS256 算法即 HMAC-SHA-256)作为签名方案。点对点认证消息(如 Webhook)直接用 HMAC;需要「自描述、可被第三方发行方签发、多方校验」的令牌时用 JWT。想看两者的结构,可以用 JWT 解码器。
HMAC 选 SHA-256 还是 SHA-512?#
除非对端要求,否则选 SHA-256。它是 Webhook 签名的事实标准,在多数 64 位服务器上更快,256 位摘要也远超任何暴力破解的可行范围。只有在 32 位目标上 SHA-512 才可能更快——这是选它的唯一常见理由。
报文是 gzip 压缩的怎么办?#
签名和校验都要针对同一份字节。服务商签的是解压后的报文;如果你拿收到的压缩字节校验,先解压,再按文档对明文字节做 HMAC。