Also available in
HMAC 经常出现在 API 请求签名、Webhook 验证和服务间通信中。它解决的核心问题不是“把请求内容加密”,而是让服务器判断:这个请求是否由持有正确共享密钥的一方生成,以及参与签名的数据在传输过程中有没有被修改。
一种常见设计是,把 HTTP Method、请求路径、Query String、部分请求 Header、请求体摘要、Timestamp 和 Nonce 等内容按照约定规则组成 Canonical Request,再使用共享密钥计算 HMAC-SHA256。服务器收到请求后,用同样的规则重新计算签名,与客户端提交的签名进行比较。
不过,HMAC 本身不能代替 HTTPS、权限控制和限流。Timestamp 也不能单独彻底阻止重放攻击。真正用于生产环境时,还要处理签名覆盖范围、请求规范化、Nonce、密钥保存和轮换,以及多实例环境下的防重放状态。
HMAC 是什么?
HMAC 全称为 Hash-based Message Authentication Code,即“基于哈希的消息认证码”。RFC 2104 将它定义为一种结合密码学哈希函数和共享 Secret Key 的消息认证机制。
可以简单理解成:
HMAC = Hash Function + Secret Key + Message
普通 SHA-256 哈希只需要输入数据:
SHA256(message)
HMAC 还需要一把通信双方共享的 Secret Key:
HMAC-SHA256(secret_key, message)
因此,只知道消息内容并不足以生成合法 HMAC。攻击者还必须掌握对应的 Secret Key。
这也是 HMAC 和普通文件哈希很重要的区别:普通哈希可以用来检查数据是否发生变化,但任何拿到数据的人都能重新计算;HMAC 加入了共享密钥,可以同时用于验证消息完整性以及消息是否由掌握这把密钥的一方生成。
这里也有一个边界需要说明:因为通信双方持有的是同一把共享密钥,HMAC 不能像 RSA、ECDSA 这类公钥数字签名那样证明究竟是哪一个密钥持有方生成了某条消息,也不提供数字签名意义上的不可否认性。
HMAC-SHA256 如何用于 API 请求签名?
真实 API 通常不会只对 Request Body 计算 HMAC,因为服务器还需要确认“到底是哪一个请求”被签名了。
例如客户端准备发送:
POST /api/orders?currency=CNY
请求体:
{
"product_id": "A1024",
"quantity": 2
}
可以先计算请求体摘要:
body_hash = SHA256(request_body)
再按照双方约定,把真正影响请求语义的字段组合成 Canonical Request。一个简化示例可以是:
POST
/api/orders
currency=CNY
content-type:application/json
x-client-id:client_123
x-timestamp:1712345678
x-nonce:550e8400-e29b-41d4-a716-446655440000
content-type;x-client-id;x-timestamp;x-nonce
<body_sha256>
这里分别包含:
- HTTP Method
- 规范化后的请求 Path
- 规范化后的 Query String
- 参与签名的 Headers
- Signed Headers 列表
- Timestamp
- Nonce
- 请求体摘要
这并不是 HMAC 的统一标准格式。你的 API 可以定义自己的协议,关键在于:客户端和服务器必须明确约定哪些字段参与签名,以及这些字段如何规范化。
如果 Query String 会改变请求含义,却没有参与签名,那么攻击者可能修改查询参数而不破坏签名。同理,真正影响业务逻辑的重要 Header,也应该考虑纳入签名覆盖范围。
客户端使用 Secret Key 计算 HMAC-SHA256:
signature = HMAC-SHA256(secret_key, canonical_request)
再通过 Header 发送签名所需的信息:
X-Client-Id: client_123
X-Timestamp: 1712345678
X-Nonce: 550e8400-e29b-41d4-a716-446655440000
X-Signature: <signature>
Secret Key 本身不应该随请求发送。
Client ID 或 Key ID 的作用只是告诉服务器应该使用哪一把密钥,它不是“公钥”。HMAC 属于共享密钥机制,与 RSA、ECDSA 这类公私钥签名体系不同。
请求体到底应该怎么参与签名?
这里是实现 HMAC API 时很容易踩坑的地方。
一种方案是直接对 HTTP 收到的原始 Request Body 字节计算 SHA-256:
SHA256(raw_request_body_bytes)
这种情况下,服务器应该使用收到的原始字节进行验证,而不是把 JSON 解析后重新生成一遍。
例如下面两段 JSON 从业务数据看可能完全一样:
{"a":1,"b":2}
和:
{
"a": 1,
"b": 2
}
但它们的原始字节不同,因此 SHA-256 结果也会不同。
另一种方案是协议明确要求先把 JSON 转换成统一的 Canonical JSON,再参与签名。如果这样做,就必须定义清楚字符编码、字段顺序、数字表示、转义、空白字符和序列化方式。
不要让不同客户端“各自觉得自己的 JSON 等价”。签名验证面对的是确定的字节和确定的规范化规则,而不是抽象意义上的“数据差不多一样”。
服务器如何验证 HMAC 签名?
服务器收到请求后,可以按下面的逻辑处理。
1. 根据 Client ID 找到对应密钥
服务器根据 Client ID、Key ID 或其他客户端标识找到对应的 Secret Key。
2. 检查 Timestamp 是否明显过期
服务器可以先拒绝明显超过时间窗口的请求,例如签名时间已经远远早于当前时间。
允许的时间窗口应根据业务场景确定,而不是机械套用一个固定数字。窗口太长会增加截获签名的可利用时间,太短又可能受到客户端和服务器时钟偏差影响。
3. 检查 Nonce 是否已经出现过
Nonce 是每次请求使用的唯一值。服务器可以查询当前有效时间窗口中,这个 Client ID 是否已经使用过相同 Nonce。
这里有一个实现细节:不要因为收到一个未经验证的请求,就立即把攻击者提供的 Nonce 永久标记成“已使用”。否则攻击者可能批量发送伪造请求,占用合法 Nonce 或制造拒绝服务。
比较稳妥的做法是把 Nonce 检查与签名验证结合起来,在确认请求真实性后再通过原子操作记录或消费 Nonce。
4. 重新计算 Request Body Hash
body_hash = SHA256(raw_request_body_bytes)
如果你的协议使用 Canonical JSON,则按照双方规定的序列化方式生成相同输入。
5. 重建 Canonical Request
服务器必须严格使用与客户端相同的规则处理:
- HTTP Method
- URI / Path 规范化
- Query String 编码与排序
- 参与签名的 Headers
- Header 名称大小写和空白
- 字符编码
- 字段顺序
- 换行符
- Request Body 处理方式
只要其中一个地方不同,即使请求合法,最终 HMAC 也可能不一致。
6. 重新计算 HMAC
expected_signature =
HMAC-SHA256(secret_key, canonical_request)
7. 使用恒定时间方法比较签名
把服务器计算出的 expected_signature 和请求里的 X-Signature 比较。
不要自己写普通字符串循环比较敏感签名。应该使用语言或密码学库提供的恒定时间比较函数(constant-time comparison),降低普通比较方式带来的时序侧信道风险。
8. 记录或消费 Nonce
签名验证成功后,再通过原子方式把这个 Nonce 记录为已使用,并设置与签名有效窗口相匹配的过期时间。
如果 API 有多个实例,Nonce 状态需要让所有实例都能看到。例如可以使用支持原子“仅不存在时写入”和自动过期的共享存储,而不是让每台 API Server 各自维护一份内存列表。
9. 执行 Authorization
签名通过并不代表客户端可以访问所有资源。
服务器仍然要判断这个 Client ID:
- 可以调用哪些 API
- 允许执行哪些 HTTP Method
- 可以访问哪些账户或资源
- 拥有哪些 Scope / Permission
- 是否超过调用额度
认证成功不等于授权成功。
HMAC 为什么能够验证数据完整性?
假设客户端原本提交:
{
"amount": 100
}
请求体 SHA-256 被放入了 Canonical Request,并最终参与 HMAC 计算。
如果请求中途被修改成:
{
"amount": 10000
}
请求体 Hash 会发生变化,Canonical Request 也随之改变,服务器重新计算出来的 HMAC 就不会再与客户端签名一致:
client_signature != server_signature
服务器因此可以拒绝请求。
但这里有一个前提:只有被纳入签名计算的数据,才能得到这层保护。
如果签名只覆盖 Request Body,却没有覆盖 Path 或关键 Query 参数,那么未被签名的部分仍然可能被修改。
RFC 9421 在讨论 HTTP Message Signatures 时也强调了相同原则:验证方只能信任真正被签名覆盖的消息组件。因此设计 Canonical Request 时,重点不是“字段越多越安全”,而是确保真正影响请求语义和权限的数据被正确覆盖。
为什么 HMAC 签名需要 Timestamp?
签名正确,只能说明被签名的数据没有被修改,并且签名由掌握共享密钥的一方生成。它并不能自动证明“这个请求是刚刚生成的”。
假设攻击者截获了一条合法付款请求:
POST /api/payment
X-Timestamp: 1712345678
X-Nonce: a8f3e1d2
X-Signature: valid-signature
攻击者可能不知道 Secret Key,也无法修改金额后重新生成签名,却可以把整条原始请求原封不动再次发送。
这就是 Replay Attack,也就是重放攻击。
Timestamp 可以限制签名的有效时间:
abs(server_time - request_timestamp) < allowed_window
超过时间窗口后,即使原始签名仍然完全正确,服务器也会拒绝请求。
只有 Timestamp 能阻止重放攻击吗?
不能。
假设服务器允许几分钟的时钟误差,那么攻击者如果在这个有效窗口内立刻重放请求,Timestamp 仍然可能合法。
因此,高风险 API 通常还需要一个每次请求都不同的:
Nonce
Nonce 本身也必须参与签名。
服务器可以在签名有效期内保存已经使用过的:
client_id + nonce + expiry
再次看到相同 Nonce 时,就可以判断这不是一个新的请求。
在多实例 API 中,这份状态不能只存在某一台服务器的内存里。需要让所有实例共享,例如放入 Redis 等具有原子写入和 TTL 能力的存储中。
Timestamp 和 Nonce 解决的问题不同:
- Timestamp:限制截获签名可以被利用多久
- Nonce:识别同一有效期内被重复使用的请求
对于支付、下单、发券这类写操作,还可以增加业务幂等键(Idempotency Key)。
Nonce 和幂等键不是同一个东西。Nonce 主要用于安全层判断签名请求有没有被重放;幂等键解决的是业务层重复执行问题,例如客户端因为网络超时主动重试付款请求时,服务器可以返回上一次执行结果,而不是再次扣款。
HMAC、API Key、Bearer Token 和 OAuth 2.0 有什么区别?
| 方式 | 主要用途 | 请求签名 | 检测被签名数据篡改 | 常见场景 |
|---|---|---|---|---|
| API Key | 客户端标识、访问控制 | 单独使用时通常没有 | 单独使用时通常不能 | 简单 API、调用额度识别 |
| Bearer Token | 携带访问凭据和授权信息 | 通常没有 | 主要依赖 HTTPS 和具体协议设计 | Web API、OAuth 2.0 Access Token |
| HMAC | 消息认证和完整性验证 | 有 | 可以验证被签名部分 | Server-to-Server API、Webhook、合作方 API |
| OAuth 2.0 | 授权框架 | 取决于具体方案 | 取决于 Token 和协议设计 | 第三方应用授权、用户授权 |
这个表格描述的是这些机制最常见的用法,不代表它们只能四选一。
例如 API Key 可以只用于识别调用方,也可以作为某套 HMAC 协议中查找 Secret Key 的 Key ID;OAuth 2.0 可以负责授权,某些系统还会在特定高风险接口上增加额外的请求完整性保护。
HMAC 和 JWT 是一回事吗?
不是。
HMAC 是一种消息认证码构造;JWT 是一种 Token 格式。
JWT 可以使用不同算法进行完整性保护,其中 HS256 就使用 HMAC-SHA256。
但“使用 JWT”并不等于“对每一个 HTTP 请求的 Method、Path、Query、Headers、Timestamp 和 Body 单独进行 HMAC 请求签名”。
它们处在不同层次,解决的问题也不完全一样。
现实中的 HMAC API 签名是怎么做的?
AWS Signature Version 4
AWS Signature Version 4 是 Canonical Request 设计中很典型的真实案例。
AWS 的 Canonical Request 会明确组合 HTTP Method、Canonical URI、Canonical Query String、Canonical Headers、Signed Headers 和 Hashed Payload,再对规范化结果进行哈希并生成最终签名。
Query 参数需要进行编码和排序,参与签名的 Header 名称和内容也有明确的规范化规则。
这和本文简化示例表达的是同一个核心原则:发送方与验证方必须对完全相同的消息组件,使用完全相同的规范化规则进行计算。
GitHub Webhook
GitHub Webhook 是另一种更简单的 HMAC 实例。
配置 Webhook Secret 后,GitHub 会使用 Secret Token 和 Webhook Payload 计算 HMAC-SHA256,并通过 X-Hub-Signature-256 Header 把结果发送给接收方。
接收方使用同一个 Secret 和收到的 Payload 重新计算 HMAC,再验证两者是否一致。
GitHub 官方文档还专门提醒,不要使用普通字符串 == 比较签名,应优先使用语言提供的安全比较函数。这正是前面提到 constant-time comparison 的实际应用。
HMAC API 还需要 HTTPS 吗?
需要。
HMAC 不能替代 HTTPS。
HMAC 主要是在应用层验证被签名消息的完整性和认证信息;HTTPS/TLS 则保护网络传输过程,包括请求正文、Token、API Key 和其他敏感数据不被轻易窃听,同时让客户端验证自己连接的服务器。
OWASP 的 REST Security 指南也明确建议安全 REST 服务只提供 HTTPS Endpoint。
即使整个请求已经有 HMAC 签名,也不应该因此允许敏感 API 通过明文 HTTP 传输。
为什么新项目更适合使用 HMAC-SHA256?
旧的 API 设计资料中仍然可以看到 HMAC-MD5 或其他较老方案。
RFC 2104 本身发布于 1997 年,当时列举过 MD5 和 SHA-1 等底层哈希函数;后续 RFC 6151 更新了 MD5 的安全建议,并明确指出新的协议设计不应该再采用 HMAC-MD5,同时给出了 HMAC-SHA256 等替代方案。
因此,如果今天设计一套新的 API 签名协议,使用成熟密码库提供的 HMAC-SHA256 是更合理的常见选择。
也不建议自己实现 HMAC 算法。直接使用操作系统、语言标准库或成熟密码学库已经提供的实现。
哪些场景适合使用 HMAC 请求签名?
HMAC 比较适合通信双方都能够安全保存共享密钥的场景,例如:
- 服务端与服务端 API 调用
- 合作伙伴 API
- Webhook 请求验证
- 支付、订单等需要验证请求完整性的接口
- 部分内部微服务通信
如果客户端是浏览器 JavaScript、公开发布的桌面程序或普通手机 App,就需要谨慎。
原因是客户端程序最终运行在用户控制的设备上,很难保证长期共享 Secret 不被提取。
把 Secret 写入 App、做代码混淆或者藏在前端变量里,并不会让它真正变成只能服务器掌握的秘密。
设计 HMAC API 时容易踩的坑
Secret Key 放在 URL 中
不要这样做。URL 可能进入浏览器历史、代理日志、服务器日志、监控系统和分析工具。
只签名 Request Body
如果 Method、Path、Query String 或某些 Header 会改变请求语义,就应该考虑把它们一起纳入签名覆盖范围。
Canonical Request 没有明确定义
“双方把这些字段拼起来”还不够。URI 编码、Query 排序、Header 大小写、空白字符、换行符和请求体字节都需要明确。
Timestamp 有了就不检查 Nonce
Timestamp 只能限制重放请求的有效期,不能自动阻止时间窗口内的快速重放。
Nonce 只存在单台服务器内存里
多实例部署时,请求可能第一次落到 Server A,重放时落到 Server B。防重放状态需要跨实例保持一致。
收到 Nonce 就立即标记为已使用
未经验证的请求可能来自攻击者。Nonce 的检查、验签和消费需要避免产生可被利用的状态污染,并尽量使用原子操作。
把 Client ID 当成 Secret
Client ID 可以出现在请求中。真正需要保护的是对应的 Secret Key。
把 HMAC 当成加密
HMAC 不会把请求正文变成不可读的数据。
如果发送:
amount=100
HMAC 不会把 amount=100 变成密文。传输保密仍然主要依赖 HTTPS/TLS。
没有密钥轮换机制
生产环境应该提前考虑:
- Key ID
- 新旧密钥短期并存
- Key Revocation
- Key Rotation
- 密钥泄露后的应急处理
否则一把 Secret 泄露后,很难在不中断现有客户端的情况下完成迁移。
没有 Rate Limiting
签名正确不意味着客户端可以无限调用接口。
仍然应该根据用户、Client ID、API Key、资源消耗等维度做限流,并在适合的情况下返回 HTTP 429。
HMAC API 安全检查清单
- API 只通过 HTTPS 提供
- 新协议优先采用成熟密码库中的 HMAC-SHA256 等现代方案
- Secret Key 不随请求发送
- Secret Key 不硬编码在公开客户端
- 明确 Canonical Request 格式
- 明确 Query String 编码与排序规则
- 明确哪些 Headers 参与签名
- 明确 Request Body 使用原始字节还是 Canonical Serialization
- 签名覆盖真正影响请求语义的数据
- 包含 Timestamp 并限制有效时间
- 高风险接口使用 Nonce 防重放
- 多实例环境共享防重放状态
- 验签成功后原子记录或消费 Nonce
- 高价值写操作考虑 Idempotency Key
- 使用恒定时间方法比较签名
- 验签后仍执行 Authorization
- 配置 Rate Limiting
- 支持 Key Rotation 和 Revoke
- 日志中避免记录 Secret 和敏感凭据
常见问题
HMAC 和 SHA-256 有什么区别?
SHA-256 是密码学哈希函数,只要拥有输入数据,任何人都可以计算对应的 Hash。HMAC-SHA256 则把共享 Secret Key 加入消息认证过程,只有掌握密钥的一方才能生成对应的 HMAC。
API 使用 HMAC 后还需要 Token 吗?
取决于系统设计。HMAC 主要解决请求消息认证和被签名数据完整性问题,Token 通常用于携带身份、授权或访问凭据。一个系统完全可能同时使用两种机制。
HMAC Secret 可以放在浏览器前端吗?
通常不应该。只要 Secret 必须随 JavaScript、桌面程序或移动 App 一起分发,就很难把它长期视为真正保密的服务器 Secret。HMAC 请求签名更适合通信双方都能安全保存共享密钥的场景。
Timestamp 能完全防止 API 重放攻击吗?
不能。Timestamp 只能缩短一个合法签名可以被再次使用的时间。攻击者仍可能在有效时间窗口内重放请求,因此高风险接口通常还要结合 Nonce、请求唯一标识或业务幂等机制。
参考资料
- RFC 2104 — HMAC: Keyed-Hashing for Message Authentication
- RFC 6151 — Updated Security Considerations for MD5 and HMAC-MD5
- RFC 9421 — HTTP Message Signatures
- AWS Identity and Access Management — Signature Version 4 signing process
- GitHub Docs — Validating webhook deliveries
- OWASP Cheat Sheet Series — REST Security
写在最后
HMAC 算法本身并不难,API 请求签名真正容易出问题的是协议设计和实现细节。
只做一个 HMAC(secret, body) 往往不够。用于生产 API 时,需要同时考虑 Method、Path、Query、Headers、Request Body 的签名覆盖范围,Canonical Request 的规范化方式,以及 Timestamp、Nonce、防重放、权限控制、限流和 Secret 生命周期。
对于 Server-to-Server API、Webhook 和合作方接口,HMAC-SHA256 仍然是一种很实用的方案。但如果系统已经使用成熟的 OAuth 2.0、短期 Access Token 或其他协议,也没必要为了“看起来更安全”机械叠加一层自定义签名。
更重要的是先明确要防什么:请求被篡改、凭据泄露、重放、越权还是资源滥用,再让每一种安全机制解决它真正负责的问题。





评论(0)