Stripe webhooks carry real money events: payment confirmations, subscription renewals, refunds, disputes. A webhook endpoint that doesn't verify its payloads is an open door for attackers to forge events — triggering fake "payment succeeded" notifications and bypassing your payment logic entirely.
This guide covers the complete, production-ready implementation for secure Stripe webhook handling in Express, including the two most common vulnerabilities teams introduce when they build this themselves.
How Stripe Webhook Signatures Work
Every webhook request Stripe sends includes a Stripe-Signature header that looks like this:
t=1736956800,v1=5257a869...abc,v1=abcdef12...345
It contains:
t: The Unix timestamp when Stripe generated the signaturev1: One or more HMAC-SHA256 signatures (multiple values during key rotation)
To verify the signature, you reconstruct the signed payload:
signed_payload = timestamp + "." + raw_request_body
Then compute HMAC-SHA256(signed_payload, webhook_secret) and compare it to each v1 value. If any match, the request is authentic.
Critical: The signature is over the raw bytes of the request body. If your middleware has already JSON-parsed the body, the signed payload no longer matches. This is the most common implementation mistake.
Setting Up Express Correctly
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const app = express();
// ✅ Use express.raw() for the webhook route — NOT express.json()
// This preserves the raw body bytes required for signature verification.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"];
if (!sig) {
return res.status(400).send("Missing Stripe-Signature header");
}
let event: Stripe.Event;
try {
// stripe.webhooks.constructEvent handles:
// 1. Signature verification
// 2. Replay attack prevention (default: 300-second tolerance)
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
// Acknowledge receipt immediately — process async
res.status(200).json({ received: true });
// Process the event (do NOT block the response)
await processStripeEvent(event).catch(console.error);
}
);
Why express.raw() and not express.json()?
express.json() parses the body into a JavaScript object, changing the byte representation. Even a single space difference makes the HMAC comparison fail. The Stripe SDK's constructEvent expects the original buffer — either a Buffer or string with the exact bytes Stripe sent.
If your Express app uses express.json() globally, place the webhook route before the global middleware registration, or use a route-specific middleware that overrides it.
Replay Attack Prevention
stripe.webhooks.constructEvent rejects signatures older than 300 seconds (5 minutes) by default. This window prevents replay attacks — where an attacker captures a valid webhook payload and re-sends it to trigger an action a second time.
You can configure the tolerance:
// Tighten the window to 60 seconds for extra security
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret, 60);
// Disable timestamp checking entirely (NOT recommended for production)
// event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret, null);
300 seconds is Stripe's recommended value and handles clock skew between your server and Stripe's infrastructure. Tightening it is fine; loosening or disabling it creates a genuine security risk.
Idempotency: Handle Duplicate Deliveries
Stripe guarantees at-least-once delivery. Under normal conditions, each event arrives once. Under network failures or retries (Stripe will retry failed deliveries for up to 72 hours), you may receive the same event multiple times.
Your handler must be idempotent — processing an event twice should produce the same result as processing it once.
// Track processed event IDs in a fast cache (Redis or in-memory Set)
const processedEvents = new Set<string>();
async function processStripeEvent(event: Stripe.Event): Promise<void> {
if (processedEvents.has(event.id)) {
console.log(`Skipping duplicate event: ${event.id}`);
return;
}
switch (event.type) {
case "payment_intent.succeeded": {
const intent = event.data.object as Stripe.PaymentIntent;
await fulfillOrder(intent);
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await cancelSubscription(sub.customer as string);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await notifyPaymentFailure(invoice);
break;
}
default:
// Unknown events — log and ignore
console.log(`Unhandled event type: ${event.type}`);
}
processedEvents.add(event.id);
// In production: persist to database to survive restarts
}
For production systems, persist processed event IDs to a database with a TTL (72 hours matches Stripe's retry window). An in-memory Set works for development but won't survive server restarts.
Webhook Secret Rotation
Stripe allows you to rotate webhook signing secrets without downtime. During the rotation window, Stripe signs events with both the old and new secret. The v1 field in the signature header contains two values — one per secret.
// stripe.webhooks.constructEvent verifies against ALL v1 signatures automatically
// No code change needed for rotation; just update the environment variable
// after the rotation window expires.
Rotate your webhook secrets:
- Every time a developer who had access to the secret leaves the team
- If you suspect the secret was exposed in logs or error messages
- As part of periodic credential rotation (quarterly minimum)
Testing Your Webhook Handler
For signature-verified testing, use the Stripe CLI:
# Install Stripe CLI, then forward real events to your local endpoint
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger a test event
stripe trigger payment_intent.succeeded
The CLI generates valid signatures automatically, so your constructEvent call works correctly in your local environment.
Security Checklist
Before going live with your webhook endpoint:
- Route uses
express.raw()— notexpress.json() -
STRIPE_WEBHOOK_SECRETis loaded from environment (never hardcoded) - Timestamp tolerance is 300 seconds or tighter
- Handler responds
200 OKbefore processing (prevents Stripe timeout retries) - Event processing is idempotent (duplicate deliveries handled)
- Processed event IDs persisted to survive restarts
- Webhook secret rotation procedure documented and tested
Getting webhook security right is a one-time investment that protects against a broad class of payment logic attacks. The implementation above has been used in production handling millions of events per month.
For the Stripe Radar and 3D Secure documentation referenced in this guide, see the official Stripe Webhooks docs.