LEAKCADE

Your Stripe webhook can be faked

Your payment webhook is a public URL. If the handler trusts whatever JSON arrives, anyone who finds it can send a checkout.session.completed event and get your paid plan, credits or order for free. Stripe signs every event so you can tell real ones from fakes, but only if your code checks the signature.

What the bug looks like

This is the pattern to look for. The handler parses the body as JSON and acts on it right away:

export async function POST(req: Request) {
  const event = await req.json()        // anyone can send this
  if (event.type === "checkout.session.completed") {
    await db.user.update({ where: { id: event.data.object.metadata.userId },
                           data: { plan: "pro" } })
  }
  return Response.json({ received: true })
}

Nothing here asks whether Stripe sent the request. A single curl with a made-up user id upgrades that account. Stripe's docs describe the same risk: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records."

The webhook URL is often easy to guess: /api/webhook, /api/stripe/webhook, /api/webhooks/stripe. Don't count on nobody finding it.

Why AI-built apps ship it

Signature checks need the raw request body, exactly as Stripe sent it. Stripe's docs warn that frameworks which parse or change the body first make verification fail. In local testing that shows up as Webhook signature verification failed, and the quickest way to make the error go away is to drop the check and read req.json() instead. The payment flow then works, so nobody looks again.

Demo shortcuts stay in too. One handler we found had the comment "For hackathon demo: Accept simulated payments. In production, verify Stripe signature" sitting above code that added the payment amount to a campaign total.

What we found in public repos

We searched public GitHub code for Next.js route handlers that read checkout.session.completed from req.json() and ran our payment check on 10 of them. Six had no signature check. Five of those wrote to the database: setting a user's plan, saving a subscription, or adding money to a total. The sixth was an empty stub that didn't do anything yet. The other four verified with constructEvent, and our check left them alone. So did all 5 handlers in a control sample that used constructEvent.

That's a small sample, so don't read it as a rate. It does show the pattern is easy to find.

How to fix it in Next.js

  1. Read the body as text with await req.text(), not req.json().
  2. Verify it with stripe.webhooks.constructEvent(body, signature, secret), using the Stripe-Signature header and your endpoint's whsec_ secret.
  3. Return 400 when verification fails, before touching the database.
  4. Delete any "simulate webhook" or test-payment routes before launch.
export async function POST(req: Request) {
  const body = await req.text()
  const sig = req.headers.get("stripe-signature")!
  let event
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response("Bad signature", { status: 400 })
  }
  // now it's safe to act on event.type
}

The stripe listen CLI prints a different secret than the one in your Dashboard. Use the CLI secret locally and the Dashboard one in production.

Don't unlock Pro on the success page

A /success page that sets is_pro = true can be opened by anyone who types the URL. Stripe's fulfillment guide says webhooks are required, because a customer can pay and close the tab before the page loads. If the success page should confirm right away, take the session id from the URL, retrieve the Checkout Session on the server and check its payment_status before granting anything.

Fix prompt you can paste

Find every payment webhook handler (Stripe, Lemon Squeezy) in this repo.
For each one: read the raw body with req.text() instead of req.json(),
verify it (Stripe: stripe.webhooks.constructEvent with the Stripe-Signature
header and STRIPE_WEBHOOK_SECRET; Lemon Squeezy: HMAC-SHA256 of the raw
body vs X-Signature with a timing-safe compare) and return 400 on failure
before any database write. Remove simulate/test webhook routes. Make sure
no success page grants a paid plan without retrieving the session on the
server. Add a test that a request with a bad signature changes nothing.

How Leakcade checks this

Our payment check reads your repo as text. It flags Stripe and Lemon Squeezy webhook handlers that act on payment events without a signature check, success pages that grant the paid plan without asking the payment provider, and paid flags written from browser code. A handler that verifies in a shared helper can look unverified to us, and the report says so.

An empty webhook stub gets a lower severity: it can't hurt you yet. Add the check before you add the logic and Drip stays happy.

Further reading

Sources

  1. Stripe docs: Manage webhook endpoints (signatures, best practices)
  2. Stripe docs: Fulfill orders
  3. Leakcade: Your VITE_ and NEXT_PUBLIC_ variables are public
  4. Leakcade: Vibe coding security checklist

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.