ProdGuard

Guide

How do I know my Stripe webhook is verifying signatures?

Open your webhook handler and look for a call to stripe.webhooks.constructEvent(). If the handler reads the request body and acts on event.type without calling it, your endpoint will believe anyone who posts to it.

A Stripe webhook URL is public. Nothing stops a stranger sending this:

curl -X POST https://yourapp.com/api/stripe-webhook \
  -H "content-type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{"customer":"cus_theirId"}}}'

If your handler trusts that, they have just given themselves a paid account. Signature verification is the only thing that distinguishes a real Stripe event from a forged one.

The correct shape

Verify before you read

import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export async function POST(req) {
  const raw = await req.text()                         // the RAW body, not JSON
  const sig = req.headers.get('stripe-signature')

  let event
  try {
    event = stripe.webhooks.constructEvent(
      raw, sig, process.env.STRIPE_WEBHOOK_SECRET,
    )
  } catch (err) {
    return new Response(`Invalid signature: ${err.message}`, { status: 400 })
  }

  // Only now is `event` trustworthy.
  if (event.type === 'checkout.session.completed') { /* ... */ }
  return new Response('ok')
}
The mistake almost everyone makes once

Parsed bodies break verification

Stripe signs the exact bytes it sent. Any middleware that parses the body first — most commonly express.json() — hands constructEvent a re-serialised object, and verification fails for every legitimate event.

The symptom is confusing: real webhooks start returning 400 while forged ones would too, so it looks like the check is working. It is failing closed, which is safe, but your app stops receiving payments.

// Express: mount the raw parser on the webhook route only
app.post('/api/stripe-webhook',
  express.raw({ type: 'application/json' }),
  handler,
)
app.use(express.json())   // everything else, after

The dangerous resolution is the one an AI agent reaches for when asked to "fix the failing webhook": remove the verification. That makes the errors stop and the endpoint forgeable, and the diff looks like a cleanup.

Checking yours

Three things to confirm

  1. The call exists in code, not in a comment. A // TODO: verify signature is not verification, and it is surprisingly common in handlers written quickly.
  2. The raw body reaches it. req.text() in Next.js App Router, express.raw() in Express, and no global JSON parser in front of it.
  3. Failure returns an error. A catch that logs and then carries on processing the event is the same as no verification at all.

Stripe's CLI lets you prove it end to end. stripe listen --forward-to sends genuinely signed events; a plain curl like the one above should get a 400.

Keeping it

Catching it if it is removed later

ProdGuard is a free, open-source command that reads your repository and fails the build when a webhook handler has no signature verification — and it does not accept a comment as proof:

npx prodguard check

It also checks eleven other things AI agents remove, including Row Level Security. Zero dependencies, MIT, nothing leaves your machine. What it does and does not catch.