Build the mental model
Your webhook endpoint is a public URL. Anyone on the internet who discovers or guesses that URL can send it a POST request shaped exactly like a real event. Nothing about the URL itself proves the request came from your provider.
The fix is signature verification. The provider computes an HMAC signature over the request body using a shared secret. Your server recomputes the same HMAC and compares — matching means accept, mismatched means reject immediately with a 401.
- Only accept webhooks over HTTPS so signatures and payloads can't be read or altered in transit.
- Consider checking a timestamp in the request and rejecting anything too old to prevent replay attacks.
Signature verification is not optional hardening — it is the one thing standing between your endpoint and anyone on the internet pretending to be your payment provider.
- Webhook Signature
- A cryptographic hash computed over a webhook request's body using a shared secret, sent in a request header so the receiver can confirm the request wasn't forged or altered.
- HMAC
- Hash-based Message Authentication Code — a way to combine a secret key with a message using a hash function so that only someone who knows the key can produce a valid signature for that message.
WEBHOOK SIGNATURE VERIFICATION FLOW
-----------------------------------
-----
Incoming POST request with body + X-Signature header
|
v
Compute HMAC-SHA256(body, shared_secret) on your server
|
v
Compare computed signature to X-Signature header value
|
+----+----+
| |
match no match
| |
v v
ACCEPT REJECT
process 401 Unauthorized
event (do not process)Connect it to a real scenario
The code below uses Node's built-in crypto module to do exactly what happens in production — no simulation. computeSignature runs HMAC-SHA256 and returns the hex digest.
verifyWebhook recomputes the signature independently and compares it to the header, using crypto.timingSafeEqual rather than a plain equality check to avoid leaking timing information.
The script runs the check twice: once with the genuine signature, once with a deliberately tampered one. Run it yourself and confirm the genuine one is accepted and the tampered one is rejected.
Never trust a request just because it reached your webhook URL
Reaching your endpoint proves nothing about who sent it. Always verify the signature before reading or acting on the body — treat every unverified request as hostile until proven otherwise.
Try the working example
const crypto = require("crypto");
const sharedSecret = "whsec_test_12345";
const payload = JSON.stringify({ type: "payment.completed", data: { orderId: "ord_482", amount: 4200 } });
function computeSignature(body, secret) {
return crypto.createHmac("sha256", secret).update(body).digest("hex");
}
function verifyWebhook(body, secret, signatureHeader) {
const expected = computeSignature(body, secret);
const isValid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
return isValid ? "ACCEPTED: signature matches" : "REJECTED: signature mismatch (401)";
}
const genuineSignature = computeSignature(payload, sharedSecret);
const tamperedSignature = genuineSignature.slice(0, -4) + "0000";
console.log("Payload:", payload);
console.log("Genuine signature:", genuineSignature);
console.log("Tampered signature:", tamperedSignature);
console.log("Check with genuine signature ->", verifyWebhook(payload, sharedSecret, genuineSignature));
console.log("Check with tampered signature ->", verifyWebhook(payload, sharedSecret, tamperedSignature));Payload: {"type":"payment.completed","data":{"orderId":"ord_482","amount":4200}}
Genuine signature: 40cc1dccb8401a94ef06c53b4a300a0112a6df9d8cf6f06962044edfadb37696
Tampered signature: 40cc1dccb8401a94ef06c53b4a300a0112a6df9d8cf6f06962044edfadb30000
Check with genuine signature -> ACCEPTED: signature matches
Check with tampered signature -> REJECTED: signature mismatch (401)5-minute try-it
Add a checkTimestamp function that rejects any webhook whose included timestamp is more than 5 minutes old, and wire it into verifyWebhook before the signature check.
One important caution
Comparing signatures with a plain === or string equality check instead of a timing-safe comparison function.
Verifying the signature against a re-serialized or reformatted body instead of the exact raw bytes the provider signed.
HMAC — Wikipedia — API Integration & Webhooks