Blog · Engineering
Verifying HMAC-SHA256 signed webhooks without security mistakes
How to check an HMAC-SHA256 webhook signature over the raw body, in constant time and with a timestamp window, with working code for Node.js and Web Crypto.
To verify an HMAC-SHA256 signed webhook, recompute the signature with your secret over the exact body you received, compare it with the one in the header in constant time, and reject requests whose timestamp is too old. Nearly every security hole, and nearly every "the signature never matches" bug, comes from skipping one of those three steps. Here they are, with working code for Node.js and Web Crypto.
In short:
- Verify the raw body before parsing JSON: re-serialized JSON almost never produces the same bytes.
- Compare with
crypto.timingSafeEqualorcrypto.subtle.verify, never with===.- Reject timestamps more than 5 minutes off and drop repeated events by their id.
- Use the secret exactly as issued, keep it in an environment variable and out of your logs.
- Return a 2xx quickly and push slow work to a queue.
How an HMAC webhook signature works
HMAC, defined in RFC 2104, combines a hash function (SHA-256 here) with a secret key that only the sender and you know. Without the key nobody can produce a valid signature for a given body, and changing a single byte of the body yields a completely different signature. HTTPS protects the connection, but it does not stop anyone from POSTing to your URL. The signature is what proves who sent the request and that nobody altered it.
A common format puts a timestamp inside the signed message. This is how castly webhooks arrive, for example:
Castly-Signature: t=1763049608,v1=5f2b…c9e1
The sender computes HMAC-SHA256 with the secret over 1763049608. followed by the body, and sends the hex result as v1. Because t is part of the signed message, an attacker cannot change the time on a captured request without breaking the signature. Your job is to repeat that exact computation and compare.
Mistake 1: verifying parsed JSON
The most common bug is letting the framework parse the JSON and then verifying JSON.stringify(req.body). The result may have different whitespace, key order or escaping (é versus é), and one different byte is enough to fail. The "fix" that shows up far too often is turning verification off, which is exactly what you must not do.
The rule: read the body as text or bytes, verify, and only then call JSON.parse. Depending on your stack:
- Express: use
express.raw({ type: 'application/json' })on the webhook route and make sure a globalexpress.json()does not run first. - Next.js App Router, Cloudflare Workers and Hono:
await request.text()(in Hono,await c.req.text()). - Behind a proxy: check that no middleware decompresses, re-encodes or trims the body before your code sees it.
Mistake 2: comparing signatures with ===
A regular string comparison stops at the first mismatching character. By timing how long your server takes to reject forged signatures, an attacker could in principle discover the correct one byte by byte. Over a real network that attack is hard, but the defense costs one line:
- Node.js:
crypto.timingSafeEqual(a, b)compares twoBuffers in constant time. It throws if their lengths differ, so validate the format first: a hex SHA-256 signature is exactly 64 characters. - Web Crypto (Workers, Deno, Bun): import the key with the
verifyusage and letcrypto.subtle.verify()do the comparison instead of computing the signature and comparing it yourself.
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const signature = Uint8Array.from(v1.match(/../g), (h) => parseInt(h, 16));
const valid = await crypto.subtle.verify('HMAC', key, signature, enc.encode(`${t}.${body}`));
Mistake 3: accepting old signatures
A valid signature stays valid forever. If someone captures a request (a poorly protected log, a debugging proxy), they can replay it days later and your server will accept it. The signed timestamp prevents that: reject any t more than 5 minutes away from your clock, in either direction.
- Check that
tis a number. In JavaScript,Math.abs(now - Number('abc')) > 300isfalse, because every comparison withNaNis. A garbagetwould slip past that check. - Keep your clock in sync with NTP. A drift of a few minutes will make you reject legitimate calls.
- Drop duplicates. Within the window, the same request can arrive twice. Store the
idof each processed event for a little longer than the window and ignore repeats.
Mistake 4: mishandling the secret
- Use it exactly as issued. Some providers expect you to base64-decode part of it; with castly, the HMAC key is the full
whsec_…string, prefix included. Strip the prefix and nothing will ever match. - Store it as a secret: an environment variable or secrets manager, never the repository or code that ships to browsers.
- Keep it out of logs. Do not dump full request headers; for debugging, log whether verification passed and the event id.
- One per environment. Production and staging should have different secrets so a leak in testing does not compromise production.
A complete verifier in Node.js
Putting it all together, an Express route looks like this. The verification function is pure and takes the current time as a parameter, which makes it easy to test:
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.WEBHOOK_SECRET; // the full whsec_… value
const seen = new Set(); // in production: Redis or your database, with expiry
export function verifySignature(body, header, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split(/=(.*)/s).slice(0, 2)),
);
const { t, v1 } = parts;
if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false;
if (Math.abs(now - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}
const app = express();
app.post('/webhooks/push', express.raw({ type: 'application/json' }), (req, res) => {
const body = req.body.toString('utf8');
if (!verifySignature(body, req.get('Castly-Signature') ?? '', SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
if (!seen.has(event.id)) {
seen.add(event.id);
enqueue(event); // slow work happens outside the request
}
res.sendStatus(200);
});
Reply with a 2xx as soon as the check passes; the docs ask for a response within 10 seconds. Webhooks from castly are best effort and not retried, so if your server was down, reconcile by querying the notifications API. The webhooks section of the docs lists every event type.
Test it before production
Signature checks are critical code and easy to test, because you can sign payloads yourself with a test secret. Using Node's built-in runner:
import test from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { verifySignature } from './webhook.js';
const secret = 'whsec_test';
const body = '{"id":"evt_1","type":"notification.clicked"}';
const now = 1763049608;
const sign = (t, b) => crypto.createHmac('sha256', secret).update(`${t}.${b}`).digest('hex');
const header = (t, b) => `t=${t},v1=${sign(t, b)}`;
test('accepts a valid signature', () => {
assert.equal(verifySignature(body, header(now, body), secret, now), true);
});
test('rejects a modified body', () => {
assert.equal(verifySignature(body + ' ', header(now, body), secret, now), false);
});
test('rejects a 10-minute-old signature', () => {
assert.equal(verifySignature(body, header(now - 600, body), secret, now), false);
});
test('rejects malformed headers without throwing', () => {
assert.equal(verifySignature(body, 'v1=abc', secret, now), false);
assert.equal(verifySignature(body, `t=abc,v1=${sign(now, body)}`, secret, now), false);
});
Add a case with a different secret and one with a signature of the wrong length: that is exactly the input that would make timingSafeEqual throw if you forgot to validate the format.
Frequently asked questions
Why does verification fail when the secret is correct?
Almost always because you are signing a different body from the one received: re-serialized JSON, a middleware that already parsed it, or a proxy that changed it. Also confirm you use the full secret, the right header, and that the server clock is not drifting.
What HTTP status should I return for an invalid signature?
A 400 or 401 with no details in the response body. Explaining the exact reason helps an attacker more than it helps you; keep that in your internal logs.
How large should the timestamp tolerance be?
Five minutes is a common choice: it absorbs clock drift and network delays without leaving a wide window for replays. Make it much tighter and you will reject legitimate calls whenever latency spikes.
Isn't HTTPS or a hard-to-guess URL enough?
No. HTTPS encrypts the connection but does not authenticate the caller, and a "secret" URL ends up in logs, screenshots and browser history. The signature is what proves origin and body integrity.
Can I verify the signature in the browser?
No: the secret would be exposed to anyone who opens developer tools. Webhooks are always received and verified on your server or in a serverless function.