An API key in a header proves the sender knows the key. It says nothing about whether the request body arrived as sent, and it means anyone who captures the request has everything needed to make more.
HMAC signing computes a cryptographic hash of the request content using a shared secret. The receiver recomputes it and compares. A match proves both that the sender knew the secret and that the content is exactly what was signed.
What changed in 2026
- Signing became standard for webhooks. Providers sending webhooks overwhelmingly sign them, and consumers increasingly verify.
- Timestamp requirements became universal. Replay windows moved from good practice to specification.
- Timing-safe comparison guidance spread. The requirement became widely known rather than specialist.
- Raw-body handling got documented. Framework middleware consuming the body before verification became a well-known failure.
Identity versus integrity
|
Bearer token |
HMAC signature |
| Proves sender knows the secret |
Yes |
Yes |
| Proves the body was not altered |
No |
Yes |
| Reusable if captured |
Yes, entirely |
Only within the replay window |
| Secret transmitted |
Yes, every request |
No — only the signature |
| Per-request |
No |
Yes |
The row that matters most is the last-but-one. A bearer token is sent with every request, so anyone who observes one request has the credential. An HMAC signature is derived from the secret without revealing it — observing a request gives you a signature valid only for that exact content.
Timestamps stop replay
A signature alone proves the request was genuine at some point. It does not prevent someone capturing a valid request and sending it again.
For an operation like "transfer funds" or "delete resource", replay is the whole attack. The solution is to include a timestamp in the signed content and reject requests whose timestamp is outside a short window.
That bounds replay to the window — typically a few minutes, enough to tolerate clock skew and network delay without leaving a useful attack surface.
The timestamp must be inside the signed content. A timestamp header that is not signed can be modified by an attacker, which defeats the purpose entirely. This is a common implementation error.
For operations that must not repeat even within the window, add an idempotency key so a replayed request is recognised and ignored — see idempotency explained.
Two implementation traps
Constant-time comparison. Comparing signatures with ordinary string equality returns as soon as it finds a mismatched byte. That means comparison takes slightly longer for a signature matching more leading bytes, and an attacker measuring response times can determine the correct signature byte by byte.
Every language provides a timing-safe comparison function. Use it. This is not a theoretical concern.
Raw body bytes. The signature covers exact bytes. If your framework parses the body into an object and your verification code re-serialises it, the bytes differ — key order, whitespace, number formatting — and verification fails for legitimate requests, intermittently and confusingly.
Capture the raw body before any parsing middleware touches it, and verify against that. This is the most common practical failure in webhook verification, and it produces a bug that appears to depend on the content.
Common mistakes
- Ordinary string comparison. Leaks the signature through timing.
- No timestamp. Captured requests replay indefinitely.
- Timestamp outside the signed content. Attacker-modifiable.
- Verifying against re-serialised body. Intermittent verification failures.
- Logging the secret. It ends up in log aggregation.
- No secret rotation path. Accept two secrets during rotation, or you cannot rotate without downtime.
- Inventing a scheme when the provider specifies one. Follow the spec exactly.
FAQ
Is this better than mutual TLS?
Different layer. Mutual TLS authenticates the connection; signing authenticates the request. Signing survives proxies and load balancers that terminate TLS, which is why webhooks use it.
How long a replay window?
A few minutes — long enough for clock skew and retries, short enough to limit exposure. Requiring tighter clock synchronisation than your senders can achieve produces spurious failures.
How do I rotate the secret?
Accept signatures from either the old or new secret during an overlap period, then retire the old one. Without that, rotation means a window where requests fail.
What should be signed?
Whatever the specification says, and typically the timestamp, the raw body, and often the method and path. Signing the body alone allows an attacker to replay it against a different endpoint.
Where to go next
For the webhook-specific application, read webhook signing. For preventing duplicate processing, idempotency explained, and for the rate-limiting layer alongside, API rate limiting.