Every SaaS has one or more webhook endpoints, and they are almost always the most exposed public surface in the product. Stripe sends payment events, GitHub sends repository events, Shopify sends order events — and if an attacker can forge a request to any of them, you have a production-grade problem. The good news: the pattern for doing this correctly is the same across every reputable provider. Capture the raw body before parsing, verify an HMAC-SHA256 signature with a timing-safe comparison, reject anything older than your replay window, and deduplicate by event ID. Miss any one of those four and the endpoint is a liability. Here's the full pattern, with code you can paste into a Node service today.
Why signature verification is not optional
A webhook endpoint that does not verify signatures trusts anything that POSTs to it. An attacker who discovers your Stripe webhook URL can send a forged `checkout.session.completed` event and your system will happily grant access, ship the product, or extend the subscription — no payment required. Signature verification proves two things: the event came from the provider, and the payload has not been altered in transit. Both require the raw request bytes.
If your Express app has `express.json()` mounted globally, it parses the body before your webhook handler runs — and HMAC verification on the re-serialised JSON will fail because key ordering and whitespace drift. Mount `express.raw({ type: 'application/json' })` on the webhook route specifically, before any JSON parser.
A correct Stripe webhook handler in Node
Stripe signs each webhook with an endpoint-specific secret. The Stripe-Signature header carries a timestamp and one or more v1 signatures, each an HMAC-SHA256 of `${timestamp}.${rawBody}` using the endpoint secret. Here's the full handler, including raw-body capture, signature verification, replay window, and idempotent processing.
import express from "express";
import Stripe from "stripe";
import crypto from "node:crypto";
const stripe = new Stripe(process.env.STRIPE_KEY!);
const app = express();
// Raw body on the webhook route ONLY — any JSON parser on this path breaks HMAC
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"];
if (typeof signature !== "string") return res.status(400).end();
let event: Stripe.Event;
try {
// constructEvent does: parse signature header, recompute HMAC,
// timing-safe compare, and enforce a default 5-minute tolerance
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature OR timestamp outside tolerance → reject
return res.status(400).end();
}
// Idempotency — record event.id; skip if we've processed it
const firstTime = await redis.set(
`webhook:stripe:${event.id}`,
"1",
"NX",
"EX",
60 * 60 * 24 * 7
);
if (firstTime !== "OK") return res.status(200).end();
// Acknowledge fast; do heavy work async
res.status(200).end();
await queue.enqueue("stripe.event", event);
}
);Four things to notice. First, `express.raw` is mounted on the route, not globally. Second, `constructEvent` does all three security checks — signature validity, signature freshness (replay window), and timing-safe comparison. Third, idempotency happens before any business logic, using Redis `SET NX EX` so the check and claim are atomic. Fourth, the handler returns 200 fast and enqueues the event for async processing — Stripe retries if it doesn't hear back within a few seconds, so never do synchronous database writes on the webhook path.
Writing HMAC verification by hand (when you have to)
Most providers ship an SDK that verifies for you. Sometimes you have to roll it yourself — a regional gateway, a custom internal system, or an older provider. The pattern is the same: compute HMAC-SHA256 of the signed payload with the shared secret, compare in constant time. Never use `==` or `===` for signature comparison — it short-circuits on the first differing byte and leaks timing information that lets an attacker reconstruct the signature one byte at a time.
import crypto from "node:crypto";
function verifyHmacSha256(
rawBody: Buffer,
receivedSignature: string,
secret: string
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Both buffers must be the same length for timingSafeEqual
const expectedBuf = Buffer.from(expected, "hex");
const receivedBuf = Buffer.from(receivedSignature, "hex");
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}`crypto.timingSafeEqual` throws if the two buffers are different lengths — always check length first and return false, or the handler crashes on a malformed signature and leaks information via the error response.
Replay protection — the step most teams skip
A valid signature proves the payload came from the provider. It does not prove the payload is recent. An attacker who captures a real webhook — via compromised logs, a man-in-the-middle on an insecure proxy, or a misconfigured reverse proxy that logs request bodies — can replay it indefinitely. The signature remains valid because the payload hasn't changed. Two controls stop this: a timestamp window, and idempotency.
- Timestamp window: reject any request with a timestamp older than your tolerance (Stripe's default is 5 minutes). The timestamp must be part of the signed payload — otherwise an attacker can forge it.
- Idempotency: record the event ID the first time you process it. On replay (or a legitimate retry), skip. This defends against both replays and the normal provider behaviour of retrying on 5xx.
- Nonce: for schemes that don't include a timestamp, the event ID doubles as a nonce — you track seen IDs in a store with a TTL longer than the provider's retry window.
Idempotency in depth
Idempotency is the property that processing the same event twice has the same effect as processing it once. Every reputable webhook provider retries on failure — Stripe retries for up to 3 days, GitHub retries for 8 hours, Shopify retries 19 times over 48 hours. If your handler crashes after writing to the database but before responding 200, the provider will retry, and a non-idempotent handler will double-charge, double-ship, or double-email.
The cleanest pattern is a deduplication store — Redis or Postgres — keyed on the event ID, with a TTL longer than the provider's retry window. The claim must be atomic: check-then-set creates a race condition where two concurrent deliveries both think they're first. Use `SETNX` in Redis or a unique constraint on a `processed_events` table in Postgres. In the handler, attempt to claim the event; if the claim fails, the event has already been seen — return 200 without processing.
// Postgres-backed idempotency using a unique constraint
// CREATE TABLE processed_events (
// event_id TEXT PRIMARY KEY,
// provider TEXT NOT NULL,
// processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
// );
async function claimEvent(
provider: string,
eventId: string
): Promise<boolean> {
try {
await db.query(
"INSERT INTO processed_events (event_id, provider) VALUES ($1, $2)",
[eventId, provider]
);
return true; // first time
} catch (err: any) {
if (err.code === "23505") return false; // duplicate — already processed
throw err;
}
}Comparison of webhook signing schemes
Every major provider uses HMAC-SHA256 underneath, but the details differ. Knowing which header to read and how the signed payload is constructed saves hours of debugging on integration day.
| Provider | Header | Signed payload | Replay defence | SDK verify |
|---|---|---|---|---|
| Stripe | Stripe-Signature | timestamp.rawBody | 5-minute timestamp tolerance | stripe.webhooks.constructEvent |
| GitHub | X-Hub-Signature-256 | rawBody | Delivery ID for idempotency | Manual (hex digest) |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody (base64 digest) | Webhook ID for idempotency | Manual (base64 digest) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0:timestamp:rawBody | 5-minute timestamp tolerance | Manual verify |
| Svix / Clerk / Resend | svix-signature + svix-timestamp | msgId.timestamp.rawBody | Timestamp tolerance | svix SDK |
GitHub and Shopify are the common manual-verify cases. GitHub emits a hex-encoded digest prefixed with `sha256=`; Shopify emits a base64-encoded digest with no prefix. In both, the signed payload is just the raw body — there's no timestamp in the signature, so replay defence relies on tracking delivery IDs (`X-GitHub-Delivery`, `X-Shopify-Webhook-Id`) as idempotency keys.
Operational hygiene
Signature verification is the baseline. A few operational practices separate a robust webhook endpoint from a fragile one.
- Respond fast, process async. Webhook providers treat slow endpoints as failures and retry — which inflates your load and multiplies the chance of duplicates. Acknowledge within 200ms; process the payload on a queue.
- Rotate secrets. Most providers support multiple active signing secrets so you can rotate without downtime. Do it annually, and immediately after any suspected compromise.
- Log the event ID and signature validity, never the payload. Webhook payloads frequently contain PII. Log metadata, not bodies.
- Monitor failure rates. A sudden spike in signature verification failures is usually either a rotation gone wrong or an attacker probing the endpoint.
- Pin your endpoint behind a reverse proxy that does not log request bodies — captured webhooks in access logs are exactly the raw material for a replay attack.
For high-volume webhooks, put the receiver on a separate service from your main API. A compromise in the webhook path should not expose the rest of the application, and scaling characteristics are different — webhooks are bursty, user traffic is smoother.
Key takeaways
- Always capture the raw body on the webhook route specifically — any global JSON parser breaks HMAC verification.
- Use `crypto.timingSafeEqual` or the provider's SDK. Never compare signatures with `==`.
- Signature verification does not stop replays — add a timestamp window and an idempotency key backed by an atomic claim.
- Respond 200 fast and queue the work. Webhook providers retry on slow responses, which amplifies load and duplicates.
- Know the scheme differences — Stripe, GitHub, Shopify, and Slack all use HMAC-SHA256 but signed payloads, headers, and replay defences differ.