工具
指南
本页内容

2026年8月9日

HMAC 与 API 请求签名:用真实数据走一遍完整流程

明明全站都上了 TLS,流量谁也篡改不了——然后某天对接方要求你给每个请求都加一个 X-Signature 头,你开始琢磨为什么。答案在于:TLS 和签名解决的是两个不同的问题,而且这道缝真实存在。这篇指南把 API 请求签名的完整实践走一遍:密钥哈希为什么胜过裸哈希、如何构造双方一致的规范串、时间戳和 nonce 如何挡住重放攻击、以及 JWT 在其中扮演什么角色。文中每一个摘要都是真实可复现的——你可以在本站的 HMAC 生成器里逐个核对,全程不出浏览器。

都有 TLS 了,为什么还要签名#

TLS 保护的是”连接”。它保证两端之间传输的字节不被读取、不被篡改。但它不保证:

  • 请求是谁发的。 TLS 在服务器(或负载均衡、或 CDN)处终结。终结点之后的一切——内部队列、日志管道、代理跳数——处理的又是明文。你的应用从反向代理收到的那个 HTTP 请求,可能来自内网的任何一台机器。
  • 请求没有被”合法地”转发过。 重试、Webhook 重投、消息队列重放,都会让流量经可信基础设施再次发送。TLS 对这些来者不拒;只有带时间戳的签名能区分”有人刻意重发”和”正常的重复送达”。
  • 解密之后的完整性。 日志中间件的 bug、配错的代理,都可能在 TLS 交完差之后改动报文。在应用层校验的签名能抓住这种改动——摘要对不上了。

所以请求签名不是 TLS 的替代品,而是第二道、应用层的封条:把”特定的调用方”(靠共享密钥)与”特定的报文”(靠摘要)绑定在一起。支付服务商、云存储 API、Webhook 发送方无一例外要求它,原因就在这里。

HMAC 是什么,裸哈希为什么不够#

HMAC——基于哈希的消息认证码,RFC 2104 定义、RFC 4231 给出 SHA-2 系列测试向量——是一种把密钥混进哈希函数的构造。你算的不再是 SHA-256(消息),而是 HMAC-SHA-256(密钥, 消息)。密钥被折进两轮带填充的哈希里(数学上这很重要:它让”长度扩展攻击”失效,那种攻击打破的正是朴素的 hash(key || message) 写法),但工程上的结论更简单:

裸哈希谁都能算;HMAC 只有持钥者能算。

这一句话就是全部理由。用真实数据看一遍。取这个 API 报文:

{"id":"evt_1001","type":"order.paid","amount":16600,"currency":"USD"}

想把金额改成 990000 的攻击者,重算篡改后报文的裸 SHA-256 毫无障碍,和你一样容易:

sha256(原始报文)  = 4a261f114b99584887c583817ce67ca7e74b23860b8775c332cfcf008fe3c409
sha256(篡改报文)  = 2b3f929865ee7557dd6c67a619ab0aab409627b27b1a8e40d7130e44cf4a5665

两个摘要看上去都”像真的”。随报文附一个校验和什么也证明不了——攻击者把两个一起换掉就行。再看同样场景下的 HMAC,密钥攻击者拿不到——哪怕他能看见两个摘要:

hmac(原始报文)    = f977d47dd3b47ab9cb8ad3074d1ffa6341d4c136a2c919edffe4b5fd2dc9ca94
hmac(篡改报文)    = 0101935e37507a084f089bcbb9ae4057980a4ac8c4a2afef3ec8bfd1c17ef25e

攻击者想生成第二行,唯一的办法是拿真密钥跑 HMAC——他做不到。篡改后的摘要无论如何也匹配不上接收方的预期。上面四个值全部用密钥 whsec_demo_4f9a2c8e17b0d5f3 算出;把报文和密钥粘进 HMAC 工具,选 SHA-256 和十六进制输出,第一行 HMAC 会一字不差地复现。它就是本指南后续所有内容的”基准真值”。

还有一条边界要守住:HMAC 做认证,不做加密。上面的报文仍是明文。如果请求还需要机密性,那是另一套机制的事。

一步一步构造一个签名请求#

真实世界的签名从不只对报文体做哈希。要让签名有意义,它必须覆盖接收方会照办的一切。标准做法是规范串:把请求的关键部分按约定顺序序列化。

假设你要调的 API 规定:对 METHOD \n PATH \n BODY \n TIMESTAMP 做 HMAC-SHA-256,十六进制输出,签名、时间戳和接入 ID 一并放请求头。对 POST https://api.example.com/api/v1/orders、上面那个报文、Unix 时间戳 1754742000 来说,规范串是:

POST
/api/v1/orders
{"id":"evt_1001","type":"order.paid","amount":16600,"currency":"USD"}
1754742000

用演示密钥对它做 HMAC-SHA-256:

c425bde754d3b757ad49e21011adaf89f25425ab9938768bade29f2a91aca256

于是整个请求长这样:

POST /api/v1/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Api-Id: ak_demo_7f21
X-Timestamp: 1754742000
X-Signature: c425bde754d3b757ad49e21011adaf89f25425ab9938768bade29f2a91aca256

{"id":"evt_1001","type":"order.paid","amount":16600,"currency":"USD"}

把流程一般化:

  1. 严格按 API 文档构造规范串。 方法、路径、查询串(若纳入则按键排序)、请求头(名小写、值去空白)、报文体、时间戳——文档列了什么、什么顺序,就照做什么。这是协议不是建议;两边必须推出逐字节一致的串,否则一切免谈。
  2. 用共享密钥对规范串做 HMAC,用指定的哈希(除非另有说明,SHA-256)和指定的输出编码(hex 与 base64 都常见,编码的是同一串字节)。
  3. 把签名连同重算所需的一切发出去——时间戳、密钥标识、算法(若 API 支持多种)。接收方无法重新推导的签名只是装饰。
  4. 接收端:先重算,再做常数时间比较。 从收到的请求重建规范串、算 HMAC、用常数时间函数比对(Node 的 crypto.timingSafeEqual、Python 的 hmac.compare_digest)。普通字符串比较会泄露前多少字节相同,高频接口上这种泄露可以被利用。
  5. 然后才解析、才执行。 校验永远先于解析——先验后析,不是先析后验。

防重放:时间戳与 nonce#

一个被截获的、签名合法的请求,就是签名合法的请求——签名本身无法告诉接收方”发送方现在是不是真的想发它”。这正是时间戳的用途。接收方拿 X-Timestamp 与服务器时间比对,超出容差窗口(通常双向各五分钟,用来吸收时钟偏差)即拒绝。但窗口之内,截获的请求仍可重发最多五分钟;要堵住这最后一段,需要 nonce(一次性随机数):

  1. 发送方生成一个唯一值(随机 UUID 即可),既放进规范串、也放进一个请求头。
  2. 接收方把每个已接受的 nonce 连同时间戳存起来,窗口内再见到同一个 nonce 就拒绝。
  3. 窗口之外的 nonce 可以忘掉——携带它们的请求早就被时间戳检查拦下了。

在这个前提下组合是无懈可击的:重放的请求要么带着过期时间戳(被时钟拒绝),要么带着已用过的 nonce(被缓存拒绝)。Webhook 发送方用更轻量的同类做法——Stripe 风格的签名把时间戳放进被签材料里,验签方对 "${t}.${body}" 做哈希并拒绝过期的 t。

一条运维提示:容差窗口本质是时钟偏差预算。如果你的签名服务器漂移超过两三分钟,你将追查一堆”其实是时间问题的神秘签名失败”。给所有参与签名的机器加时钟同步监控。

实例演练:验证一个 Webhook 风格的签名#

Webhook 是开发者最常第一次亲手实现 HMAC 校验(接收端)的地方。假设某服务商向 https://api.example.com/hooks 推送事件,请求头 X-Signature: sha256=<hex>,用共享密钥对报文的原始字节签名。一个最小且正确的 Node 处理器:

import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/hooks", 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");
  // 验签通过——现在才解析、才处理
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(204).end();
});

真正承重的是 express.raw:签名覆盖的是服务商发来的原始字节。如果你的框架先解析 JSON 再重新序列化,键序和空白一变,摘要必然对不上。用真实数据感受一下这个坑:拿演示报文和它的签名 f977d47d…ca94,再把同一份 JSON 用两空格缩进漂亮打印、对那份字节重新签名:

hmac(漂亮打印后的报文) = cef0b1baf7e47c11aef9fdadaacf7071f729fdb9461d6668d92ffdff7bd6ef4e

同一个 JSON 值、同一把密钥、不同的字节——摘要面目全非。这个”空白陷阱”是”签名不匹配”工单里最常见的原因,没有之一。接收方拒了你的签名时,先把你签的字节和收到的字节逐字符对一遍,再考虑其他。

要端到端检查自己的实现,用公开的测试向量。RFC 4231 第一个用例:20 字节的 0x0b 重复作密钥、消息 Hi There,HMAC-SHA-256 的十六进制输出:

b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7

你的代码对这组输入算出这个摘要,HMAC 层就是对的,任何不匹配都出在规范串构造或输出编码上。这个摘要可以在 HMAC 生成器里确认——它用的是浏览器原生 Web Crypto,与生产代码用的是同一个原语。

这和 JWT 有什么不同#

“HMAC” 和 “JWT” 都会出现在”给请求做认证”的讨论里,但两者的定位不同:

  • HMAC 签的是你手上的消息。 请求和签名一起走,接收方重算。它不是自描述的:接收方需要共享密钥和规范串约定。点对点协议——你与支付服务商、你与存储服务商,两端由同一批人配置——用它是理想选择。
  • JWT 是自带声明的自包含令牌。 它内置元数据(签发者、主体、过期时间)并对全体内容签名。值得一提的是,用 HS256 算法签名的 JWT 内部就是一个 HMAC-SHA-256——同一个原语,装进了结构化信封。当令牌要穿过若干”各自都没拿到共享密钥”的系统、或接收方需要把有效期与权限范围嵌进凭据本身时,JWT 的优势就出来了。

一个实用的划分:服务与服务之间的请求/Webhook 认证、双方共享配置的场景,用裸 HMAC 签名;令牌签发一次、多个独立方各自验证的场景,用 JWT。想看看令牌内部长什么样,本站的 JWT 解码器可以在浏览器本地把它拆开。

常见错误,按”烧到人的频率”排序#

  • 对”解析后又重新序列化”的 JSON 签名。 上面讲透了。签线上传输的原始字节。
  • 非常数时间比较。 对摘要用 == 会泄露前缀匹配长度。用你平台自带的时序安全比较。
  • 密钥可猜。 HMAC 的安全性最终归结为密钥。whsec_demo_4f9a2c8e17b0d5f3 只是演示用的 26 个字符;生产密钥应当是 32 字节以上的密码学随机源、按环境隔离、按计划轮换,轮换时留一段新旧签名都接受的重叠窗口。
  • 时间戳没进被签材料。 时间戳若只躺在请求头里、不在规范串里,攻击者就能随意改写它,你的防重放窗口形同虚设。
  • 把”签名合法”当”可以照办”。 签名证明报文完整且来自持钥者。它不证明账户有余额、退款仍待处理、事件描述的事情仍然成立。高价值操作,先通过 API 回查状态再执行。

常见问题#

HMAC 是加密吗?#

不是。它是认证。消息保持明文;摘要提供的是”字节未改 + 来自持钥者”的证明。机密性要靠在其上叠加加密,或用 AES-GCM 这类认证加密模式一次给全。

签名请求头用 hex 还是 base64?#

两者编码的是同一串摘要字节。hex 更长(SHA-256 为 64 字符)但工具链常不区分大小写、日志里干净;base64 更短(44 字符)但区分大小写,还常在”URL 安全变体”和”标准变体”之间被弄混。API 文档怎么规定就怎么来;两头都是自己设计时,hex 是更稳的默认。

为什么我本地算得对、发给服务器就失败?#

按可能性排序:规范串不一致(空白、键序、带不带查询串的路径、头名称大小写)、编码不一致(hex 对 base64、或 base64url)、时间戳超出窗口、密钥带尾随空白或用错了环境的值。先用 RFC 4231 向量复现排除 HMAC 层,再逐字节 diff 两边的规范串。

一把签名密钥到处用行吗?#

不行。至少按环境(测试、预发、生产)隔离,条件允许再按对接方隔离。与第三方共享的密钥,其安全就取决于对方的应急能力了。轮换时用双签名接受窗口——过渡期新旧密钥都验——避免停机。

哈希选哪个?#

SHA-256。它是请求签名的事实标准,在 64 位硬件上快,256 位摘要远超任何暴力破解的可行范围。只有对接方强制要求时才换 SHA-512(在 32 位平台上它偶尔更快)。SHA-1 只在遗留集成里出现。

小结#

签名补上 TLS 留下的那道缝:把调用方身份与请求的原始字节绑定,经得起代理跳转、重试和日志管道,没有密钥的攻击者无法伪造。实践收敛成一张短清单——严格按规定构造规范串、用按环境隔离的强密钥做 HMAC、常数时间比较、接收端强制时间戳窗口加 nonce 缓存、先验签后解析。出问题时,RFC 4231 向量就是你的基准真值。

在本站把例子跑一遍:用 HMAC 生成器算出文中每一个摘要,用 哈希计算器并排对比裸摘要,如果你还持有 JWT,用 JWT 解码器拆开看看内部。

← 全部指南