Anyone on the internet can POST to your webhook URL. You must prove the request came from the legitimate sender before acting on it. There is no single standard; each platform picks an approach:
| Method | Used by | How it works |
|---|---|---|
| HMAC signature header | GitHub, Stripe, Shopify | Sender signs the raw body with a shared secret, puts the hash in a header. You recompute and compare. Most common and most secure. |
| Bearer / API token | many simpler services | A secret token in the Authorization header. Simple, but can't prove body integrity. |
| Basic Auth | older systems, some CI platforms | Username + password encoded in the Authorization header. |
| mTLS (mutual TLS) | financial / enterprise | Both sides present certificates. Very strong but complex to set up. |
| IP allowlist | supplementary guard | Only accept requests from the sender's published IP ranges. Easy but fragile if their IPs change. |
How to use it
HMAC verification is the pattern worth knowing by heart. The sender computes
HMAC(secret, rawBody) and sends it in a header like x-webhook-signature.
You recompute it over the raw request body (not the parsed-and-re-stringified
JSON) and compare using a constant-time function to prevent timing attacks:
import crypto from "node:crypto";
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Constant-time comparison — never use === for signatures
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
app.post("/webhooks/receive", (req, res) => {
if (!verifySignature(req.rawBody, req.headers["x-webhook-signature"], SECRET)) {
return res.status(401).end(); // not from who it claims to be
}
res.status(200).end(); // acknowledge fast
enqueue(req.body); // process async
}); Exercise
Node challenge · runs in your browser
Verify a webhook signature
Finish verifySignature so it accepts a webhook signed with the shared
secret and rejects anything tampered with. The starter signs an empty string and
compares with ===.
- Sign the raw body, not an empty string.
- Compare with
crypto.timingSafeEqualso the check is constant-time.
node:crypto is available in this sandbox, so the tests run real HMACs.
Check your understanding
Check your understanding