HMAC webhook security: signatures done properly
Why webhooks need HMAC signatures, and how to verify them properly: raw-body signing, constant-time compares, replay windows and key rotation, with code.
Your webhook endpoint is a public door
A webhook endpoint is an unauthenticated URL that changes state in your system. Read that sentence again as an attacker would. Anyone who learns the URL (from a log, a browser history, a misconfigured proxy, or plain guessing) can POST whatever they like at it. If your handler processes 'payment received' or 'email arrived' events on trust, an attacker can manufacture those events at will.
Obscure URLs are not a defence; URLs leak. IP allowlists are brittle and don't survive the sender's infrastructure changes. The standard answer is a shared-secret signature: the sender computes an HMAC over each request, the receiver recomputes it, and a request that doesn't carry a valid signature is discarded before it touches any business logic. Simple in principle, and yet most real-world verification code gets at least one of four details wrong. Let's take them in order.
Detail 1: sign and verify the raw bytes
An HMAC is computed over an exact byte sequence. The single most common verification bug is computing it over something that merely resembles the request: parsing the JSON and re-serialising it, trimming whitespace, or letting a framework middleware decode the body before you can read it. Re-serialised JSON is not the same bytes (key order, spacing, and unicode escaping all differ), so the signature fails intermittently and someone eventually 'fixes' it by disabling verification. That story ends badly.
The rule: capture the raw request body as bytes, verify the signature against those bytes, and only then parse. In Express that means express.raw() on the webhook route; in most frameworks it means reading the body stream before any JSON middleware touches it.
Detail 2: include a timestamp, enforce a replay window
A valid signature proves who sent a request, not when. Without more, an attacker who captures one legitimate signed request (from a log file, a compromised proxy, a packet capture) can replay it a thousand times, and every copy verifies perfectly. If the event is 'top up account' or 'email arrived, trigger workflow', replays are free ammunition.
The fix is to make time part of the signed material. The sender includes a timestamp header and computes the HMAC over the timestamp concatenated with the body. The receiver rejects any request whose timestamp is outside a tolerance window. Five minutes is the conventional choice, generous enough for clock skew and retry delays, tight enough that a captured request goes stale quickly. Reject the stale request before comparing signatures; there's no reason to spend crypto cycles on it. For state-changing events, pair the window with idempotency keys so even a fast replay inside the window can't double-apply.
Detail 3: compare in constant time
The natural way to compare two signatures (a string equality check) leaks information. Naive comparisons return the moment they find a differing byte, so a forged signature that gets the first byte right takes measurably longer to reject than one that doesn't. Given enough requests and careful timing, an attacker can grind out a valid signature byte by byte. It's a niche attack, but the defence is one line, so there's no excuse.
Every serious crypto library ships a constant-time comparison: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP. Use it, and compare same-length buffers (timingSafeEqual throws on length mismatch, so check length first and fail closed).
The whole verifier, correctly
Here is all of the above in one Node handler, with raw body, timestamp window and constant-time compare:
import crypto from "node:crypto";
import express from "express";
const app = express();
const TOLERANCE_S = 300; // 5-minute replay window
app.post("/hooks/email", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Timestamp") ?? "";
const signature = req.get("X-Signature-SHA256") ?? "";
// 1. Reject stale requests before any crypto
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > TOLERANCE_S) {
return res.status(401).end();
}
// 2. Recompute over timestamp + raw bytes
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(timestamp + ".")
.update(req.body) // raw Buffer, never parsed-then-reserialised JSON
.digest("hex");
// 3. Constant-time compare, length checked first
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
// persist or enqueue, then acknowledge fast
res.status(200).end();
});Detail 4: plan for key rotation
Secrets have lifetimes. An engineer leaves, a laptop is lost, a secret lands in a log, and now you need to change the key without dropping legitimate traffic in the gap between sender and receiver updating. The naive approach (change it on both ends and hope) guarantees a window of failed deliveries.
The robust pattern is overlapping validity: the receiver accepts signatures from either the current or the previous key for a bounded grace period. The sender switches to the new key; requests signed with the old one keep verifying until the grace period ends; then the old key is retired. Some providers make this even smoother by sending multiple signatures (one per active key) in a single header, so the receiver just needs any one to match.
- Keep secrets out of code: environment variables or a secrets manager, never the repository
- Use a distinct secret per endpoint, so one leak has a one-pipeline blast radius
- Rotate on staff departures and on any suspicion of exposure, not just on schedule
- Alert on sustained signature failures: they're either a misconfiguration or someone probing
What to expect from a webhook provider
Verification is your half of the contract; the sender owes you the other half. A provider worth trusting signs every delivery with a documented scheme, includes a timestamp in the signed material, publishes exactly what bytes are signed and in what order, supports per-endpoint secrets, and offers a rotation path that doesn't drop traffic. If the documentation can't answer 'what exact string do I feed the HMAC?', you'll be reverse-engineering it from failing requests.
That checklist is worth applying to anything that POSTs into your systems: payment processors, CI systems, and email pipelines alike. When HideMy.world delivers a parsed email to your webhook, every request is signed along these lines so your handler can verify it with precisely the code above. Whoever the sender is, the discipline is the same: authenticate the bytes, bound the time, compare in constant time, and rotate without downtime. Four details, one afternoon, and your public door has an actual lock.