Securing Stripe webhooks — signature verification + idempotency
Stripe webhook handlers must verify the signature before processing. Without verification, attackers spoof events. Plus: idempotency for retry-safety.
Stripe webhooks are signed; verify the signature before processing. Otherwise attackers spoof success events.
What it is
Stripe sends webhook events for payment lifecycle. Each event includes a signature in the Stripe-Signature header. Without verification, anyone can POST a 'payment.succeeded' event.
Vulnerable example
export async function POST(req: Request) {
const event = await req.json();
if (event.type === "payment_intent.succeeded") await markOrderPaid(event.data.object.metadata.order_id);
return Response.json({ received: true });
// Vulnerable: any caller can POST a fake event
}Fixed example
import { Stripe } from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature");
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
} catch { return new Response("invalid signature", { status: 400 }); }
// Idempotency: reject already-processed event_ids
if (await db.processed_events.exists(event.id)) return Response.json({ received: true });
if (event.type === "payment_intent.succeeded") await markOrderPaid(event.data.object.metadata.order_id);
await db.processed_events.insert(event.id);
return Response.json({ received: true });
}How Securie catches it
apps/web/auth.ts:87Securing Stripe webhooks
Static-rules pattern catches webhook handlers without constructEvent signature check.
import { Stripe } from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature");
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
} catch { return new Response("invalid signature", { status: 400 }); }
// Idempotency: reject already-processed event_ids
if (await db.processed_events.exists(event.id)) return Response.json({ received: true });
if (event.type === "payment_intent.succeeded") await markOrderPaid(event.data.object.metadata.order_id);
await db.processed_events.insert(event.id);
return Response.json({ received: true });
}Checklist
- constructEvent or equivalent signature verification
- Webhook secret in env var (not committed)
- Idempotency on event_id
- Restricted Stripe key for webhook handler
- Audit-log every webhook event
FAQ
Why idempotency?
Stripe retries failed deliveries. Idempotent handlers avoid double-processing.
Related guides
If your webhook endpoint skips signature verification, an attacker can trigger any downstream action you code — refunds, subscription changes, user upgrades. Here is how to verify signatures correctly for the five most common webhook providers.
Server Actions execute server-side but default to no auth. Add session-required guard at the top of every protected action.
Verify JWTs with explicit algorithm + issuer + audience. Default verify functions accept multiple algorithms = alg-confusion attack surface.
anon key is public BY DESIGN — without RLS it's a skeleton key. Lovable Apr 2026 BOLA showed 10.3% of apps got this wrong.