Webhooks
How to Verify Webhook Signatures in Node.js (HMAC-SHA256, Done Right)
A webhook endpoint is a public URL that does something whenever it receives a request. Without a check, anyone who finds that URL can send fake events to it: fake orders, fake sign-ups, fake form submissions. Signature verification is the check. It takes about ten lines of code, but there are four details that most tutorials get wrong, and each one either breaks verification or quietly leaves a hole.
How webhook signatures work
The sender and you share a secret key. For every delivery, the sender combines the secret with the exact request body (and often a timestamp) using HMAC-SHA256, and puts the result in a header. You repeat the same calculation with your copy of the secret. If your result matches the header, the request came from someone who knows the secret, and the body wasn't changed on the way.
Providers differ only in the details:
| Sender | Signature header | What is signed | Format |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 | 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 | Body | Base64 |
| Formpit | X-Formpit-Signature | timestamp.body | v1= + hex |
The code below uses Formpit's format, where the timestamp arrives in X-Formpit-Timestamp. Adapting it to any row of that table is a matter of changing the header names and the string you sign.
Mistake 1: hashing the parsed body
This is the number one reason signatures "never match". The signature covers the exact bytes that were sent. If a body parser turns them into a JavaScript object first and you then call JSON.stringify() to get a string back, spacing, key order or number formatting can differ, and the hash is different.
In Express, use express.raw() on the webhook route so req.body is a Buffer of the original bytes. And make sure a global app.use(express.json()) doesn't run before it, because a body can only be read once.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.FORMPIT_WEBHOOK_SECRET;
// Register this BEFORE any app.use(express.json())
app.post('/webhooks/formpit',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifySignature(req.body,
req.get('X-Formpit-Timestamp'),
req.get('X-Formpit-Signature'),
SECRET);
if (!ok) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ...handle the event (see "duplicates" below)
res.sendStatus(200);
});
Mistake 2: comparing with ===
A normal string comparison stops at the first character that differs. That means a wrong signature that shares its first few characters with the right one takes very slightly longer to reject. With enough requests, an attacker can measure that and recover a valid signature one character at a time. crypto.timingSafeEqual always takes the same time.
One catch: timingSafeEqual throws if the two buffers are different lengths, so check the length first.
function verifySignature(rawBody, timestamp, signature, secret,
toleranceSeconds = 300) {
if (!rawBody || !timestamp || !signature || !secret) return false;
if (!/^\d+$/.test(timestamp)) return false;
// Mistake 3: reject old (replayed) requests
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > toleranceSeconds) return false;
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody) // the raw Buffer, not parsed JSON
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Mistake 3: no replay protection
A signature proves a request is genuine. It doesn't prove it's new. If someone captures one real delivery (from a log, a proxy, or a misconfigured tool), they can send it again tomorrow and it will still verify. That's why good senders include a timestamp inside the signed data. Your code rejects anything older than a few minutes, and because the timestamp is part of the signature, it can't be changed without breaking it.
Five minutes is a common tolerance. It allows for clock differences and retries without leaving a big window. If your server's clock drifts, fix that with NTP rather than widening the window.
Mistake 4: assuming each event arrives once
Webhooks are delivered "at least once". Networks fail, senders retry, and the same event can arrive twice. If your handler sends an email or creates an order, a duplicate means doing it twice.
Use the delivery or event ID that the sender includes (Formpit sends X-Formpit-Delivery) and remember which ones you've handled. A unique constraint in your database is the simplest way:
// Pseudocode with any SQL database
try {
await db.run('INSERT INTO handled_webhooks (id) VALUES (?)', deliveryId);
} catch (err) {
if (isUniqueViolation(err)) return res.sendStatus(200); // seen it, skip
throw err;
}
await handleEvent(event);
Also reply quickly. Most senders wait only a few seconds before treating a delivery as failed and retrying. Do the slow work (sending emails, calling other APIs) after you've returned a 200, or in a background job.
Next.js (App Router) example
In a route handler, read the raw body with req.text() before doing anything else:
// app/api/webhooks/formpit/route.js
import crypto from 'node:crypto';
export async function POST(req) {
const raw = await req.text();
const ts = req.headers.get('x-formpit-timestamp') || '';
const sig = req.headers.get('x-formpit-signature') || '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return new Response('Stale request', { status: 401 });
}
const expected = 'v1=' + crypto
.createHmac('sha256', process.env.FORMPIT_WEBHOOK_SECRET)
.update(`${ts}.${raw}`)
.digest('hex');
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(raw);
// ...handle it
return new Response('ok');
}
Python example
The same check in Python uses hmac.compare_digest for the constant-time comparison. In Flask, the raw body is request.get_data().
import hashlib, hmac, time
def verify_signature(raw: bytes, timestamp: str, signature: str,
secret: str, tolerance: int = 300) -> bool:
if not timestamp or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw,
hashlib.sha256).hexdigest()
return hmac.compare_digest(signature or "", "v1=" + mac)
Looking after the secret
- Keep it out of your code. Store it in an environment variable or your platform's secret manager, never in the repository.
- One secret per endpoint. If one leaks, you only rotate one.
- Rotate without downtime. When you create a new secret, accept both the old and new one for a short while, then drop the old one.
- Don't log full requests with their signature headers in places many people can read.
Testing your endpoint
Send yourself a real delivery and a tampered one. With Formpit, put your endpoint's URL in the form's Integrations tab, copy the signing secret shown under it, and press Send test submission. To see exactly what arrives before writing any code, point the webhook at a request inspector such as webhook.site first. Then check that your code accepts the genuine delivery and returns 401 when you change one character of the body, the signature or the timestamp.
Checklist
- Hash the raw body, not parsed JSON.
- Compare with
timingSafeEqual(orhmac.compare_digest), after a length check. - Reject timestamps older than about five minutes.
- Skip duplicate delivery IDs.
- Return 200 quickly; do slow work afterwards.
- Keep the secret in an environment variable and rotate it if it leaks.
Get those six right and your webhook endpoint is as trustworthy as the sender's own systems.
Frequently asked questions
Why does my signature never match?
Most often because the body was parsed as JSON before hashing. Hash the raw bytes exactly as they arrived.
Why use timingSafeEqual instead of ===?
A normal comparison can stop at the first different character, which leaks timing information an attacker can measure. timingSafeEqual always takes the same time.
Do I need the timestamp check?
Yes, if the sender provides one. Without it, a captured genuine request can be replayed later and will still pass the signature check.