Webhook Signature Verification: A Step-by-Step HMAC Guide
7 min read · Updated Aug 5, 2026

You verify a webhook’s HMAC signature by recomputing the hash yourself, using the shared secret and the exact raw bytes of the request body, then comparing it to the signature header the provider sent using a constant-time comparison function. If the two match, the payload came from the provider and was not altered in transit. Skip any one of those three steps (raw bytes, shared secret, constant-time compare) and the check either fails constantly or protects nothing.
Key takeaways
- HMAC (hash-based message authentication code) mixes a shared secret into a hash of the payload, so only someone holding that secret could have produced a matching signature.
- Always verify against the raw request body bytes, not a
JSON.parse()’d and re-serialized version. Whitespace and key ordering change the hash completely. - Use a constant-time comparison (Node’s
crypto.timingSafeEqual, Python’shmac.compare_digest) instead of===, or you leak timing information an attacker can use to guess the signature one byte at a time. - Add a timestamp check alongside the signature to block replay attacks: a captured valid webhook resent an hour later should be rejected even though the signature still matches.
- Stripe, GitHub, and Shopify all use HMAC-SHA256, but put the signature in different headers with slightly different formats, so read the specific provider’s docs before copying code from another integration.
What HMAC actually does to a webhook payload
A webhook provider and your server share a secret string, generated once when you set up the endpoint. Before sending a webhook, the provider runs the request body through an HMAC function together with that secret, using a hash algorithm like SHA-256, and sends the result in a header alongside the request. Your server does the exact same calculation on its end. If your result matches the header, the request could only have come from someone holding the shared secret, which should only be the provider and you.
echo -n '{"event":"payment.succeeded","amount":4200}' | \
openssl dgst -sha256 -hmac "whsec_your_shared_secret"Verifying the signature in your handler
The verification function itself is short. The part that trips people up is making sure your framework hands you the raw, unparsed body to hash, since that is what the provider actually signed.
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const expectedBuffer = Buffer.from(expected, 'hex');
const receivedBuffer = Buffer.from(signatureHeader, 'hex');
if (expectedBuffer.length !== receivedBuffer.length) return false;
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
app.post(
'/webhooks/incoming',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// handle event
res.status(200).send('ok');
},
);The mistake that took Priya an hour to find
In March 2023, a developer I’ll call Priya was wiring a Stripe webhook into a billing pipeline for a fintech client. She had a global JSON body-parser already running for the rest of the API, and the new webhook route inherited it by default. Every legitimate webhook Stripe sent failed her signature check, 340 failed verifications in the first hour, all logged as potential attacks even though nothing malicious was happening. It took her about an hour of adding console logs before she realized the body she was hashing was not the body Stripe had actually signed. Once she excluded the webhook route from the global parser and read the raw bytes instead, every signature verified on the first try.

Stop replay attacks with a timestamp check
A valid signature proves the payload was not tampered with. It does not prove the request is happening now. Anyone who captures a legitimate webhook request (a proxy log, a browser extension, a compromised CI job) can replay the exact same bytes and headers later, and your server will verify it again, because the signature is still mathematically correct. The fix is to sign a timestamp along with the payload and reject anything outside a tolerance window.
How the big providers format their signatures
| Provider | Header | Signed value |
|---|---|---|
| Stripe | Stripe-Signature | timestamp + "." + raw body, formatted as t=..,v1=.. |
| GitHub | X-Hub-Signature-256 | raw body only, prefixed with sha256= |
| Shopify | X-Shopify-Hmac-Sha256 | raw body only, base64 encoded |
| n8n (custom webhook auth) | whatever header you define | depends entirely on how you configure the Webhook node |
I think a lot of teams treat webhook signature verification as optional hardening they will add later, and later never comes until an incident review forces it. If your endpoint accepts unauthenticated JSON and triggers a database write or an outbound charge, it is not really a webhook receiver, it is an open HTTP endpoint on the public internet that happens to expect a specific shape. I would rather see a webhook route reject every unsigned request outright from day one than “soft-fail” with a warning log nobody reads until something goes wrong.

Why this is worth the extra twenty lines of code
OWASP’s API Security Top 10 (2023 edition) lists broken authentication as API2:2023, the second most common API vulnerability class, and an unauthenticated webhook endpoint is a textbook example: no proof of origin, and often a direct path to writing untrusted data straight into a database or triggering a side effect like a refund. Twenty or so lines of HMAC verification code closes that gap completely, for less effort than most teams spend arguing about which HTTP status code to return.
“A webhook without signature verification is not a lightweight integration. It is a public POST endpoint with good intentions.”
Frequently asked questions
Frequently asked questions
What is HMAC in the context of webhooks?
HMAC (hash-based message authentication code) combines a secret key with the webhook payload through a hash function, usually SHA-256, to produce a signature. Only someone holding the same secret can generate a signature that matches, which is what lets your server confirm the request really came from the provider.
Why does the raw request body matter for verification?
Because the provider signs the exact bytes it sends over the wire. If your framework parses that body into a JSON object and you re-serialize it before hashing, whitespace and key order can differ just enough to produce a completely different hash, so every legitimate request fails verification.
What is a timing-safe comparison and why do I need one?
A timing-safe (constant-time) comparison takes the same amount of time to run regardless of where two strings first differ. A normal === comparison exits early on the first mismatched byte, which an attacker can measure and exploit to guess a valid signature one byte at a time. Node’s crypto.timingSafeEqual and Python’s hmac.compare_digest exist specifically to close that gap.
How do I stop someone from replaying an old webhook?
Sign a timestamp along with the payload and check it against your server’s current time on every request, rejecting anything outside a tolerance window (Stripe defaults to five minutes). A valid signature alone only proves the payload was not tampered with, not that the request is happening now.
What happens if I skip webhook signature verification entirely?
Anyone who discovers your webhook URL can send arbitrary JSON to it and your server will process it as if it came from the real provider. Depending on what the endpoint does, that can mean fake orders, fake payment confirmations, or unauthenticated writes straight into your database.
Do Stripe, GitHub, and Shopify all verify webhooks the same way?
They all use HMAC-SHA256 under the hood, but the header name and exact signed value differ (Stripe signs "timestamp.body", GitHub and Shopify sign the raw body alone), so you cannot copy verification code from one provider to another without checking its specific documentation.