5 min read

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

Securie findinghigh
apps/web/auth.ts:87

Securing Stripe webhooks

Static-rules pattern catches webhook handlers without constructEvent signature check.

Suggested fix — ready as a PR
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 });
}
Catch this in my repo →Securie scans every PR · ships the fix as a one-click merge · free during early access

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