The first time I wired up a webhook I skipped signature verification, “just for now”. Anyone who found the URL could have told my app that an order was paid. Verification turned out to be a few lines — and then a whole afternoon of debugging, because every signature I computed was wrong. This guide covers both: how webhook signatures work, and why they fail.
I use the HMAC generator to check my server’s math against the provider’s. It runs in the browser and never stores or shares the secret.
How webhook signatures work
When you register a webhook, the provider gives you a signing secret. For each delivery, it computes an HMAC — a keyed hash — over the request body with that secret and sends the result in a header. Your server computes the same HMAC over the body it received and compares the two. Only someone with the secret can produce a matching value, so a match proves the request came from the provider and wasn’t modified.
An HMAC is not just a hash. SHA-256 of the body is something anyone can calculate; HMAC-SHA256 with a secret key isn’t.
What each provider signs
The details differ, and the details are what break:
| Provider | Header | What is signed | Encoding |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 | raw body | sha256= + hex |
| Stripe | Stripe-Signature | timestamp.body | t=…,v1= + hex |
| Slack | X-Slack-Signature | v0:timestamp:body | v0= + hex |
| Shopify | X-Shopify-Hmac-Sha256 | raw body | Base64 |
Stripe and Slack include a timestamp in what they sign, so an attacker can’t replay an old request with a fresh timestamp. Both recommend rejecting deliveries older than five minutes. Stripe may also send several v1 signatures while you rotate secrets — the delivery is valid if any of them matches.
Why verification fails
Nearly every failure I’ve seen comes from one of these:
1. The body was re-serialized. Frameworks love to parse JSON for you. If you verify against JSON.stringify(req.body) or a re-serialized model, key order, whitespace and number formatting can all differ from what was sent. You must verify the raw bytes. In Express, use express.raw({ type: 'application/json' }) on the webhook route; in ASP.NET Core, read Request.Body into a buffer (with EnableBuffering) before model binding.
2. A trailing newline. Copy a body from a log and you often pick up a newline the sender never signed. One byte changes the HMAC completely. With secret whsec_test:
{"action":"opened"} → 17f29d6ef0cd7bb57459d9a18e047afe6134af8c0893878defe8e1047c17d31a
{"action":"opened"}\n → fdf9550f3b1379be2039b203465e02f7c8b20510fddc0bf406a01e6853e11f3d
The HMAC generator warns when the body ends with a newline and has a toggle to trim it.
3. The wrong secret. The signing secret is per endpoint and is not your API key. Stripe’s starts with whsec_. If you copied it from a .env file with quotes around it, the quotes are not part of the secret — the tool warns about that too.
4. The wrong encoding. Shopify sends Base64; GitHub, Stripe and Slack send hex. Comparing a hex string to a Base64 string never matches.
Checking it by hand
When my server rejects a delivery, I paste three things into the tool’s Verify mode: the raw body from the provider’s delivery log, the signing secret, and the signature header. Pick the provider and it applies that provider’s rules, shows whether the signature matches, and shows the expected value — which I then compare with what my code computed. If the tool says valid and my code says invalid, the bug is in how my code reads the body.
Compare in constant time
Don’t compare signatures with ==. A normal string comparison returns at the first differing byte, and in principle the timing reveals how much of the signature was right. Use the platform’s constant-time comparison:
crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
CryptographicOperations.FixedTimeEquals(expectedBytes, receivedBytes);
The HMAC generator’s verifier compares decoded bytes the same way, so hex case doesn’t matter.
A quick Node.js verifier for GitHub
const crypto = require('node:crypto');
function verifyGitHub(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The length check matters: timingSafeEqual throws if the buffers differ in length.
Beyond webhooks
HMAC shows up anywhere two systems share a secret: signed URLs, API request signing, cookie integrity, and JWTs signed with HS256 — the JWT generator is HMAC with extra structure. For plain checksums with no secret, use the hash generator. And when a signature won’t match, the HMAC generator is the quickest way I know to find out whether the problem is the secret, the body, or the encoding.