A webhook endpoint is, by definition, a public URL that accepts POST requests from anywhere on the internet. Left unprotected, anyone who discovers (or guesses) that address can forge a "payment approved" event and make your system ship product for free. The good news: the defense is well understood — HMAC signatures, replay protection and a handful of practices that providers like Stripe and GitHub have applied for years. This guide explains each layer and shows the code.
Why your endpoint is an attack surface
Unlike the rest of your API, a webhook endpoint doesn't sit behind a login: the provider must reach it with no session, no cookie, no OAuth. It is a door that is always open. And what comes through it usually triggers valuable actions — marking an invoice as paid, granting access, kicking off fulfillment.
The most obvious attack requires no sophistication at all. If your system trusts any JSON that arrives with "type": "payment.approved", one well-crafted curl is enough to defraud it:
curl -X POST https://your-app.com/webhooks/payments \
-H "Content-Type: application/json" \
-d '{"type":"payment.approved","data":{"payment_id":"pay_fake","amount":14990}}'The question webhook security answers is: how do you know the request really came from the provider and wasn't altered along the way? The industry-standard answer is the HMAC signature.
HMAC signatures: how they work
HMAC (Hash-based Message Authentication Code) combines a hash function — almost always SHA-256 — with a shared secret known only to the provider and you. The flow:
- When you register the webhook, the provider generates (or you define) a secret, e.g.
whsec_a1b2c3…. It is stored on both sides and never travels with the deliveries. - Before sending each event, the provider computes
HMAC-SHA256(secret, body)over the exact bytes of the payload and ships the result in a header — something likeX-Hub-Signature-256orStripe-Signature. - On receipt, you redo the same computation with your copy of the secret and compare. If it matches, the message is authentic and intact; if it doesn't, discard it.
Since only someone who knows the secret can produce a valid signature, an attacker may know your URL and the payload format — without the secret, no forged POST gets through. And because the hash covers the entire body, any byte altered in transit also invalidates the signature.
Verifying in Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
// The body MUST arrive raw: use express.raw, not express.json,
// on this route — the signature was computed over the original bytes.
app.post(
"/webhooks/payments",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.WEBHOOK_SECRET;
const received = req.get("x-signature-sha256") ?? "";
const expected = crypto
.createHmac("sha256", secret)
.update(req.body) // Buffer with the raw body
.digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end(); // no details about the reason
}
const event = JSON.parse(req.body.toString("utf8"));
// enqueue the processing and respond fast
res.status(200).end();
},
);Two details in this code break more integrations than anything else:
- Use the raw body. If a middleware parses the JSON and you recompute the HMAC over
JSON.stringify(req.body), verification will fail intermittently: re-serialization can change key order, unicode escapes and whitespace — different bytes, different hash. The signature is always verified over the bytes exactly as they arrived. - Compare with
timingSafeEqual. A regular comparison (===) returns faster the earlier the strings diverge, and that response time leaks information that lets an attacker reconstruct the signature byte by byte — the so-called timing attack. Constant-time comparison closes that channel.
The headers where each provider places this signature — and the other headers that accompany a delivery — are dissected in Anatomy of a webhook request.
Replay attacks: the signature alone is not enough
Suppose an attacker manages to capture one complete, legitimate delivery — headers and body — through an exposed log, a misconfigured proxy or a forgotten inspection endpoint. They don't know the secret, but they don't need to: the signature for that specific message is already there. All they have to do is resend the same request, untouched, as many times as they want. That's a replay attack — and pure HMAC verification accepts every copy.
The standard defense is to include a timestamp in the signed material and reject old messages. That's exactly how the Stripe webhook is designed: the Stripe-Signature header carries t= (timestamp) and v1= (signature), and the HMAC is computed over {timestamp}.{body}. Because the timestamp is part of the hash, the attacker can't "refresh" an old message without breaking the signature.
const TOLERANCE_SECONDS = 5 * 60; // 5 minutes
function verifySignedWebhook(header, rawBody, secret) {
// header: "t=1784990400,v1=5257a869e7ecebeda32affa62cdca3fa..."
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (ageSeconds > TOLERANCE_SECONDS) return false; // stale message: replay?
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(parts.v1 ?? "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}The tolerance window (5 minutes is Stripe's default) exists to accommodate slightly skewed clocks and network delays. Inside the window, idempotency completes the defense: if you record the id of every processed event and ignore repeats, a replay inside the window becomes a harmless no-op.
How the major providers sign
The mechanism is the same everywhere; what changes is the header, the encoding and whether a timestamp is included:
| Provider | Header | Format | Timestamp? |
|---|---|---|---|
| Stripe | Stripe-Signature | t=…,v1=… — hex HMAC-SHA256 over t.body | Yes, in the signature |
| GitHub | X-Hub-Signature-256 | sha256=… — hex HMAC-SHA256 of the body | No |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 of the body, base64-encoded | No |
| Mercado Pago | x-signature | ts=…,v1=… — hex HMAC-SHA256 over a manifest with id and request-id | Yes, in the signature |
Before implementing, read your provider's specification — the details (what goes into the signed material, hex vs. base64) vary, and any divergence makes verification fail. GitHub's documentation on validating webhook deliveries is a good example of a well-written reference, with test vectors to check your implementation against.
Complementary best practices
The signature is the foundation, but a serious security posture adds more layers:
- HTTPS, always. Without TLS, the payload and signature travel in the clear — any intermediary can capture the pair and gain replay material. Serious providers won't even let you register an
http://URL. - One secret per endpoint, with rotation. A secret shared across environments (production, staging, dev) multiplies the leak points. Generate one per endpoint and rotate periodically — providers like Stripe support two active secrets during rotation, so you can switch without dropping deliveries.
- IP allowlists — as an extra layer, never the only one. Some providers publish their source IP ranges. Filtering on them reduces noise, but IPs change without notice, lists go stale and network origin can be spoofed in compromised-infrastructure scenarios. Treat it as defense in depth, not a substitute for the signature.
- Verification failed? 401 and silence. Respond
401(or400) with no body explaining why. Messages like "expected signature: X" are a debugging oracle for the attacker. - Don't log sensitive payloads. Payment webhooks carry emails, documents, amounts. Logs with full payloads become the leak that feeds replay and social engineering. Log metadata (event id, type, verification result) and mask the rest.
- Confirm via the API before releasing value. Even with a valid signature, a robust flow treats the webhook as a notification and queries the provider's API to confirm the state before any irreversible action — granting access, settling an invoice, issuing a refund.
Worth saying: most real-world incidents don't come from elaborate attacks, but from shortcuts — verification disabled "just in staging", a secret committed to the repository, a test endpoint forgotten in production. These and other stumbles are covered in The 10 most common webhook implementation mistakes.
Security checklist
- Endpoint serves HTTPS only.
- HMAC signature verified over the raw body, on every request, in every environment.
- Constant-time comparison (
timingSafeEqualor equivalent). - Timestamp checked against a tolerance window (when the provider signs a timestamp).
- Deduplication by event id (idempotency) covering replays inside the window.
- One secret per endpoint, kept out of the code, with a rotation plan.
- A 401 with no details when verification fails.
- Logs free of sensitive payloads; critical actions confirmed via the API.
Summary
- A webhook endpoint is a public URL: without verification, any forged POST becomes "payment approved".
- HMAC-SHA256 with a shared secret authenticates origin and integrity — always over the raw body, always with constant-time comparison.
- Replay is fought with a timestamp inside the signed material + a tolerance window + idempotency by event id.
- HTTPS, per-endpoint secrets with rotation, detail-free responses and clean logs complete the layers; IP allowlists are extra, not the foundation.
- An authenticated webhook is still a notification: confirm via the API before releasing value.