August 17, 2026
HMAC, explained: how keyed hashing protects webhooks
If you have ever wired up a payment provider or a Git hosting service, you have met a header like X-Signature or X-Hub-Signature-256. That header is almost always an HMAC. This guide explains what HMAC computes, why a bare hash is not enough for authentication, and how to verify a webhook signature correctly — including the mistakes that make signature checks useless.
You can follow along with any example on this site: the HMAC generator computes digests entirely in your browser, so you can paste a real webhook payload and a test secret without anything leaving your machine.
The problem HMAC solves#
A cryptographic hash like SHA-256 answers one question: did these bytes change? Feed it a document, get a digest. Feed it the same document again, get the same digest. Change one bit, and the digest is completely different.
That is perfect for integrity checks on a trustworthy channel — a Linux distribution can publish a SHA-256 checksum and you can compare it after downloading. But a digest published inside the same channel as the message proves nothing. If an attacker can tamper with the payload, they can recompute the hash of the tampered payload and overwrite the digest too. The digest travels with the message, so modifying both is trivial.
What webhooks need is a second property: the digest must be computable only by someone who holds a secret key shared between the sender and the receiver. Then a man-in-the-middle can rewrite the body all they like — without the key, they cannot produce a matching digest. That construction is HMAC: Hash-based Message Authentication Code, standardized in RFC 2104 and refined for the SHA-2 family in RFC 4231.
So the two questions split cleanly:
- A plain hash answers “did this byte stream change?”
- HMAC answers “did this byte stream change, and was it produced by someone who shares my secret key?”
How HMAC works internally#
You do not need the math to use HMAC safely, but a one-paragraph intuition helps you reason about key sizes and hash choices. HMAC runs the underlying hash function twice, mixing the key into each round with fixed padding blocks:
- Derive two 64-byte blocks from the key: an inner pad (
k XOR 0x36…) and an outer pad (k XOR 0x5c…). Keys shorter than the block size are padded with zeros; longer keys are first hashed down. - Hash
innerPad || messagewith, say, SHA-256. - Hash
outerPad || (result of step 2).
The output length matches the underlying hash: 32 bytes for SHA-256, 64 for SHA-512. Because the key is folded into both rounds, knowing the message gives you no shortcut to the digest — you must brute-force the key itself. The double-round structure is also why length-extension attacks (which do break the naive hash(key || message) construction) do not apply to HMAC.
Three practical consequences fall out of this:
- Key entropy is the whole game. HMAC-SHA-256 with a 256-bit random key is not meaningfully brute-forceable. The same HMAC with the key
"secret"falls to a dictionary attack in seconds. Always generate signing secrets from a CSPRNG — 32 random bytes, hex-encoded, is a good default. - The hash choice is not the weak point. SHA-1 as a signature primitive is dead, but HMAC-SHA-1 has no practical break. Still, prefer SHA-256 for anything new: it costs nothing and matches what every major provider uses.
- Never construct it yourself.
hash(key + message)is not HMAC and is broken by length extension. Every standard library has it:crypto.createHmacin Node,hmac.newin Python,crypto.createHmacvia Web Crypto in the browser.
Verifying a webhook signature, step by step#
The exact header name differs per provider — Stripe uses Stripe-Signature, GitHub uses X-Hub-Signature-256, Slack signs with X-Slack-Signature — but the verification recipe is nearly identical. Using a generic provider that sends X-Signature: sha256=<hex>:
- Read the raw request body as bytes. Not the parsed JSON — the exact bytes that were sent. Re-serializing parsed JSON changes key order and whitespace, and the digest will not match. Buffer the body before parsing it.
- Recompute the HMAC of that raw body with the shared secret, using the same hash the sender used (SHA-256 unless told otherwise).
- Compare digests in constant time. An ordinary
==string comparison short-circuits on the first differing character, which leaks how many leading bytes matched. Timing leakage on a high-traffic endpoint can be stitched into a full digest forgery. Usecrypto.timingSafeEqual(Node),hmac.compare_digest(Python), or an equivalent. - Reject anything that does not match. With a 2xx, never with a redirect. Log the failure, not the secret.
A minimal Node.js handler:
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
const expected =
"sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body) // raw bytes, NOT req.body parsed as 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");
// only now parse JSON and act on the event
res.status(204).end();
});
Two provider-specific refinements you will meet in practice:
- Signed timestamps (Stripe, Slack). The signature string includes a timestamp and the body, e.g.
t=1690000000,v1=…. Verify the HMAC over"${t}.${rawBody}"and reject requests whose timestamp is older than ~5 minutes. This defeats replay: a captured, validly signed request cannot be re-sent hours later. - Multiple signatures. Headers can carry more than one
v1=value (providers rotate keys without downtime). Accept the request if any entry matches your current or previous secret.
Worked example#
The RFC 4231 test suite uses a key of Jefe and the message what do ya want for nothing?. HMAC-SHA-256 over that pair, hex-encoded:
5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
The same inputs with HMAC-SHA-512 produce a 64-byte digest:
164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737
You can reproduce both in the HMAC tool: load the sample, pick SHA-256 vs SHA-512, and generate. If your own library produces a different digest for this pair, the bug is in your encoding step — check that the key and message are UTF-8 encoded before hashing, and that you hex-encode the raw digest bytes rather than a base64 layer on top.
Common mistakes that defeat the point#
- Parsing before verifying. Parse the JSON after the signature check, always. Parsed-then-reserialized bodies never verify, which pushes developers toward “temporarily” disabling the check — the single worst outcome.
- Non-constant-time comparison. Covered above; this is the most frequently copied security bug in webhook examples on the internet.
- A shared secret reused across environments. Use separate test and production secrets, and rotate them with the dual-signature window providers offer.
- Trusting the webhook as authorization. A valid signature proves the message came from the provider — not that the event described is still true. Re-fetch the state through the provider’s API before acting on high-value events like refunds.
- Putting the secret in front-end code. Signing secrets are for servers. If verification must happen in a browser context, it belongs to a backend route you control.
FAQ#
Is HMAC encryption?#
No. HMAC authenticates — it proves integrity and origin. The message itself remains plain text. If you need confidentiality as well, encrypt separately (for example AES-GCM), or use an authenticated-encryption mode that provides both.
HMAC or JWT?#
They solve different problems. A JWT often contains an HMAC (the HS256 algorithm is HMAC-SHA-256) as its signature scheme. Use HMAC directly for authenticating point-to-point messages like webhooks; use JWT when you need a self-describing, verifiable token carrying claims that a third party issues and others validate. You can inspect the structure of either with the JWT decoder.
SHA-256 or SHA-512 for HMAC?#
SHA-256 unless the receiving system requires otherwise. It is the industry default for webhook signing, it is faster on most 64-bit servers, and 256 bits of digest is far beyond any brute-force horizon. On 32-bit targets SHA-512 can actually be faster, which is the only common reason to prefer it.
What if the payload arrives gzip-compressed?#
Sign and verify the same bytes. Providers sign the decompressed body; if you verify against the compressed bytes you receive, decompress first, then HMAC the plain bytes exactly as the provider documented.
Where to go next#
- Compute digests and cross-check your integration with the HMAC generator — hex or base64, all four SHA variants, keys never leave the page.
- Compare plain digests side by side with the hash calculator.
- Turn a working signature check into a reproducible test by converting your provider’s sample
curlcommand with the curl converter.