工具
指南
本页内容

2026年8月9日

API 请求签名完整工作流:nonce + 时间戳 + HMAC

TLS 保护的是”数据在网络上不被偷看”,但它不保护你的 API 不被一个”加密完好、内容伪造”的请求打进来。如果你的 API 对任何能建立连接的来者都照单全收,那么任何摸到端点地址的人都能调用它。把 API key 明文放进请求头稍微好一点,但静态凭据等于一张”永久重放券”:只要被截获一次——从日志、代理、配错的分析上报管道——就永远有效。

请求签名一次性堵住这两个口子。客户端不传输密钥本身就能证明自己持有它,服务端还能拒绝”复制粘贴”来的旧请求。这篇文章把整套工作流搭完整——规范串、HMAC、时间戳窗口、nonce 缓存——再跑一个带真实摘要的完整实例,所有结果都能用本站的 HMAC 生成器在浏览器里复核,并用 curl 转换器一键变成可运行代码。

为什么要给请求签名#

按雄心从低到高,签名带来三个性质:

  1. 认证但不外泄密钥。签名由密钥和请求共同推导,密钥本身从不上线。攻击者录下全部流量,拿到的只有请求和签名——两者合起来也签不出另一个请求。
  2. **整个请求的完整性。**签名覆盖方法、路径和请求体。改动任何一个字节,签名立刻失配——这点是裸 API key 做不到的:静态 key 会乖乖跟着被篡改的负载一起到达。
  3. 新鲜度(防重放)。把时间戳和一次性 nonce 混进签名,服务端就能验证请求是现在发出的,而不是录播的。这是绝大多数自造签名方案默默缺失的性质。

横向对比一下:静态 Authorization: <key> 头三个性质一个都没有;mTLS 客户端身份很硬,但要分发证书、按请求操作也别扭;JWT 提供可过期、自描述的凭据,但它是**持有即用(bearer)**的令牌——拿到就能用到过期为止。请求签名是务实的中间道路:逐请求的强保证、不需要 PKI、每个客户端一个共享密钥。

三块积木#

HMAC:平台替你做好的带密钥哈希#

HMAC(基于哈希的消息认证码,RFC 2104 定义)让 SHA-256 这类哈希跑两轮,把密钥折进每一轮。没有密钥就算不出摘要——所以摘要本身就是”我持有密钥”的证明。务必用平台实现,绝不手搓:Node 的 crypto.createHmac、Python 的 hmac.new、浏览器的 crypto.subtle.sign 配 HMAC。密钥至少 32 个 CSPRNG 随机字节,十六进制编码后存储;用人类想的密码当签名密钥,几秒内就被字典攻击打穿。需要临时生成一个随机密钥的话,随机令牌生成器在本地用 CSPRNG 出数。

时间戳:新鲜度窗口#

每个签名请求携带当前 Unix 秒级时间。服务端检查:

|now - 请求时间戳| <= 允许偏差

典型偏差窗口是 300 秒(5 分钟)。窗口要吸收两个现实:客户端时钟有漂移;请求因重试、网络拥堵排队几秒属于正常。窗口太紧会误杀合法流量,太松则给攻击者更长的重放菜单。五分钟是事实上的行业默认值。

关键在于:时间戳必须参与签名。放在未签名的头里毫无保护意义——攻击者改一下就行。另一个重要细节:时间戳永远用 UTC(或干脆用秒级时间戳)。本地时间的时间戳把一个隐藏偏移量焊进了值里,一遇夏令时,服务端比较就变得不确定;而秒级时间戳压根没有时区,正合需要。想核对某个秒数对应的人类时间,时间戳转换器会把 UTC 和你的本地时区并排显示。

nonce:一次性使用,配缓存#

光有时间戳窗口不够:五分钟之内,被截获的请求依然能重放成功。nonce(“number used once”,一次性数字)就是补这个洞的。每个请求带一个唯一随机值;服务端记录见过的 nonce,第二次出现即拒绝。

为了让缓存有穷,把它绑定到窗口上:存 nonce -> 时间戳,时间戳早于窗口的条目直接淘汰——nonce 的时间戳一旦滑出窗口,重放请求会先死在时间戳检查上,缓存条目已无意义。12 字节 CSPRNG 输出的十六进制(24 个字符)绰绰有余。注意 nonce 只增加防重放这一层保护,它替代不了签名本身——不带签名的 nonce 攻击者随手就能换一个新的。

规范串:实现真正翻车的地方#

客户端和服务端必须对完全相同的字节做哈希。这份约定写下来就叫规范串(canonical string)——请求的确定性序列化。一个典型布局:

METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY

规范串里的每一个设计决定,都必须双方一致地执行:

  • 方法大写(POST),因为有些客户端发小写。
  • 只写路径,不含主机、端口、查询串——或者查询串按键排序后一起签,如果你要签查询参数。排序不可省略:同一组参数在线上到达的顺序可能是任意排列。
  • 时间戳和 nonce与它们出现在请求头里的形态逐字一致,线上字节和串里字节要吻合。
  • 请求体用发送的原始字节——不是重新序列化的 JSON。键顺序、空白、unicode 转义(é 与 é)都会改变摘要。这一条规则制造了大多数”签名不匹配”的工单:客户端签的是紧凑 JSON,某个 SDK 带空格重新序列化了一遍,摘要就此分道扬镳。

空请求体怎么办?签空字符串——但要在文档里写死,并让双方用同一方式处理。

规范串的常见 bug#

  • 签了重新序列化的 body,而不是原始字节。
  • 客户端签的是 /v1/orders,网关改写路径后应用收到的是 /api/v1/orders。
  • 把会被代理归一化大小写的请求头(X-Foo 与 x-foo)也签了进去。
  • 一方用换行做分隔符,另一方用空串或 &。
  • 一端做了 unicode 归一化(NFC/NFD),另一端没做。

把规范串精确到字节写进文档,附上参考测试向量,这些坑全能避开。

服务端校验,逐步来#

收到带 X-Timestamp、X-Nonce、X-Signature 三个头的请求后:

  1. **先查时间戳窗口。**解析 X-Timestamp,若 |now - ts| > 300,直接回 401,别做任何加密运算。这也是成本最低的一道防线——过期请求不碰 HMAC、不碰 nonce 缓存就被挡掉。
  2. 查 nonce 是否已用。在缓存里找这个 nonce。若已存在(且仍在窗口内),判定重放,拒绝。否则原子地占位(并发下原子性是关键——用 SETNX 风格的原语或数据库唯一约束)。
  3. 重建规范串,用的是你收到的原始字节:真实的方法、真实的路径、逐字的头值、原始请求体。
  4. 重算 HMAC,密钥按客户端 ID 查(客户端 ID 应该是单独的、不签名的头),再与 X-Signature 做常数时间比较——Node 用 timingSafeEqual,Python 用 hmac.compare_digest。普通 == 会泄露前多少字节相同。
  5. 任何一步失败都拒绝,错误信息保持含糊。失败的具体原因记在服务端日志里,绝不下发给客户端——“时间戳偏差 312 秒”这种细节是送给攻击者的免费校准器。

注意这个顺序是有意为之:便宜的检查(时间戳、nonce)跑在贵的检查(HMAC)前面,而所有”到底哪步失败”的信息只进你的日志。

实例演算:给一个真实请求签名#

理论够了——从头到尾签一个真请求。客户端要调用 POST /v1/orders,请求体:

{"amount":42,"currency":"EUR"}

参数(测试密钥,别用于生产):

secret     = 8f4a2b9c1e7d5f3a6b8c0d2e4f6a8b0c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3
timestamp  = 1723455667          (即 2024-08-12T09:41:07Z)
nonce      = a7f3c9e2

规范串,以 \n 分隔——方法、路径、时间戳、nonce,然后是请求体的逐字字节:

POST
/v1/orders
1723455667
a7f3c9e2
{"amount":42,"currency":"EUR"}

用上面的密钥对这个串做 HMAC-SHA-256,十六进制编码:

bd6b2edbf699ac91b464db66a9f1f37a09332093e24f2c6b523971870ca15938

客户端带着签名和参数发请求:

curl -X POST "https://api.example.com/v1/orders" \
  -H "X-Client-Id: shop-12345" \
  -H "X-Timestamp: 1723455667" \
  -H "X-Nonce: a7f3c9e2" \
  -H "X-Signature: bd6b2edbf699ac91b464db66a9f1f37a09332093e24f2c6b523971870ca15938" \
  -H "Content-Type: application/json" \
  -d '{"amount":42,"currency":"EUR"}'

再看篡改检测如何生效。把请求体改一个字符——42 变 4200——其余不动,修改后请求对应的正确签名是:

054871423a739b26d16dd4a2dd5ccc8138d0ff9827490dc5e5363c2003bc8ca0

攻击者没有密钥,算不出这个值。原签名对不上新请求,服务端拒绝。整个安全论证就浓缩在这一行 diff 里。

**亲手验证一遍。**打开 HMAC 生成器,算法选 SHA-256、输出选 hex,密钥栏粘入 secret,消息栏粘入五行规范串(换行要真实),得到的就是上面的 bd6b2edb…——由浏览器里 Web Crypto 这一生产级原语本地算出。然后把这条 curl 命令粘进 curl 转换器,一键得到 fetch、axios、Python requests、Go、Java 或 HTTPie 版本——现成的客户端代码片段,进文档进测试都行。

一个最小化的 Node 客户端签名函数:

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

function signRequest({ method, path, body, secret }) {
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = randomBytes(12).toString("hex").slice(0, 8);
  const canonical = [method.toUpperCase(), path, timestamp, nonce, body].join("\n");
  const signature = createHmac("sha256", secret)
    .update(canonical)
    .digest("hex");
  return { timestamp, nonce, signature };
}

服务端按上一节的 1-5 步,对同一份规范串镜像执行即可。

实际对接中会遇到的变体#

  • **签名头集合。**成熟的方案(云厂商 API、支付网关)往往把”签了哪些头”的清单也写进规范串,让认证头、幂等键一并受保护。部件更多,原则不变。
  • **多哈希算法。**签名头里可能带算法前缀(sha256=…),方便日后无痛迁到 SHA-512 或 SHA-3。只要还在接受范围内的算法都要能验。
  • **密钥轮换。**先发第二把密钥,在重叠窗口内用新密钥签、新旧都收,然后退役旧密钥。配一个 X-Key-Version 头,服务端不用猜该用哪把。
  • HMAC 还是 JWT。HS256 的 JWT 内部就是一个 HMAC——但 JWT 是持有即用的令牌:拿到就能出示直到过期,且它签的是令牌本身,不是逐请求的 body。请求签名绑定的是每一个具体请求。两者可以同时用 JWT 解码器看结构。很多系统双管齐下:登录态用 JWT,高敏操作逐请求 HMAC。
  • **入站 webhook 的防重放。**同一套时间戳机制也保护进站的 webhook——服务商签 timestamp.body,你强制五分钟窗口,做法与 HMAC 指南里 webhook 校验一节完全一致。

常见问题#

直接把 API key 放 Authorization 头不行吗?#

不行,因为请求里的静态 key 可以被无限重放,也不能证明请求其余部分没被动过。任何一条日志、一层代理缓存、一个浏览器扩展拿到这个头,就拿到了永久访问权。签名让密钥留在客户端、覆盖请求体、并在几分钟内过期。如果必须保留静态 key,至少按客户端隔离并高频轮换。

时间戳用秒还是毫秒?#

用秒。偏差窗口是分钟级,亚秒精度毫无增益,而且秒与多数服务端时钟的原生习惯一致。如果坚持用毫秒,就在规范里写死——服务端拿毫秒值跟秒级阈值比较,要么全拒、要么全放,好在症状明显。

偏差窗口设多长?#

五分钟是常见默认值。下限由最坏情况的端到端延迟加客户端时钟漂移决定(NTP 同步的客户端漂移是毫秒级;时钟坏了能漂几分钟)。如果客户端是运行在未知硬件上的浏览器,就把窗口留在五分钟,防重放主要交给 nonce——那正是 nonce 存在的意义。

nonce 缓存要扛得住重启吗?#

要严格的一次性语义,就要扛——纯内存缓存在每次部署时清空,重启后(五分钟的)重放窗口重新打开。带 TTL 的 Redis 类存储或建了唯一约束的数据库表,既能扛重启又能跨副本共享。如果对你的威胁模型来说”发布瞬间的一小段窗口”可以接受,带 TTL 的内存缓存是一个说得过去的简化——把这个取舍白纸黑字写进设计文档。

签名不匹配报错时,先查什么?#

九成是规范串。逐字节对比:原始 body 还是重新序列化的 JSON、客户端发出的路径还是网关改写后的路径、请求头大小写、分隔符换行、unicode 转义。最快的诊断办法:让客户端打印它签名的完整规范串、服务端打印它校验的完整规范串,然后做 diff。

多个环境能共用一把密钥吗?#

不能。测试和生产各用各的密钥,测试密钥泄露只是麻烦而不是事故。每把密钥独立用 CSPRNG 生成,客户端之间也绝不共用——按客户端发密钥的全部意义就在于出事时能单独吊销、不伤及无辜。

总结与工具#

整套流程一段话讲完:把请求序列化成文档化的规范串,混入新鲜的时间戳和 nonce,用按客户端发放的密钥对串做 HMAC,签名放请求头;服务端按序检查时间戳窗口、强制 nonce 一次性、从原始字节重建规范串、常数时间比较。密码学部分只有一行,实践中真正出问题的永远是规范串和工程细节——密钥存储、轮换、nonce 缓存、时钟漂移。

用这些工具搭建并验证你的实现:

  • HMAC 生成器——在浏览器里复现本文每一个摘要(SHA-256、hex 或 base64),用生产级 Web Crypto 原语给你的签名器做交叉验证。
  • curl 转换器——把签名后的 curl 命令转成 fetch、axios、Python、Go、Java 或 HTTPie,直接进文档和测试。
  • JWT 解码器——组合”会话 JWT + 逐请求签名”时,检查令牌结构和 exp 声明。
  • 随机令牌生成器——本地生成 CSPRNG 密钥和 nonce,零上传。

← 全部指南