HMAC answers a question a plain hash cannot: was this message produced by someone who holds the secret, and has it been modified since? Because the key is mixed into the computation twice, an attacker who can see the message and the signature still cannot produce a signature for a different message.
Sign the exact bytes
This is where implementations break, and it breaks silently. A framework that parses JSON before your handler runs gives you an object. JSON.stringify of that object is not byte-identical to what was sent — key order may differ, whitespace is gone, Unicode may be escaped differently. The signature will not match, and the error will look like a wrong secret.
Capture the raw body:
// Express: keep the raw bytes alongside the parsed body
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
// Next.js route handler: read the text before parsing
const raw = await request.text();
const parsed = JSON.parse(raw); // parse after signing checkCompare in constant time
A naive comparison returns as soon as two bytes differ. That timing difference is measurable over enough requests, and it lets an attacker recover the correct signature one byte at a time.
import { timingSafeEqual } from "node:crypto";
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
const valid = a.length === b.length && timingSafeEqual(a, b);The comparison on this page is constant-time for the same reason.
Encoding and prefixes
The signature is bytes; how you transmit it is a convention:
- Hex — lowercase, twice as long, unambiguous. Most common.
- Base64 — shorter. Shopify uses it.
- Prefixed — GitHub sends
sha256=before the hex, which future-proofs the header against an algorithm change.
Any of these is fine; both sides must agree exactly.
Key length
The key should be at least as long as the hash output — 32 bytes for SHA-256. HMAC hashes keys longer than the block size before use, so a 1024-bit key gains nothing. Keys shorter than 128 bits are searchable offline by an attacker holding one signed message.
HMAC is not a signature in the public-key sense
It is symmetric. Both parties hold the same secret, so either could have produced any given signature. That is fine for verifying a webhook came from a provider you share a secret with. It cannot provide non-repudiation — for that you need an asymmetric signature from the RSA key pair generator.
Frequently asked questions
Which hash should I use?
SHA-256. SHA-1 inside HMAC is not currently broken — HMAC does not require collision resistance the way a plain signature does — but there is no reason to choose it for anything new, and it fails audits.
Why does my signature not match the provider's?
In order of likelihood: you signed a re-serialised body rather than the raw bytes; the provider signs a constructed string like timestamp.body rather than the body alone; you are hex-encoding where they expect Base64; or the secret has a trailing newline. The webhook signature verifier shows the exact bytes each provider signs.
Should the timestamp be part of the signed payload?
Yes, if you want replay protection. Signing timestamp.body and rejecting old timestamps is what stops a captured request being replayed indefinitely. Stripe and Slack both do this.
Can I use HMAC to authenticate API requests generally?
Yes, and it is stronger than a bearer token: the key is never transmitted, so intercepting a request does not yield the credential. AWS SigV4 works this way — see the SigV4 builder. The cost is a more complex client.