LEAKCADE

Skip Stripe webhook signature verification and anyone can fake a payment

Updated 4 min read

Your payment webhook is a public URL. Without Stripe webhook signature verification, 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.

QUICK CHECKLIST

  • Read the body with req.text(), not req.json()
  • Verify with stripe.webhooks.constructEvent
  • Return 400 before any database write
  • Delete simulate-payment routes
  • Don't grant Pro on the success page alone

What bad Stripe webhook security looks like

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 })
}
How a fake Stripe event unlocks a paid planAn attacker posts a made-up checkout.session.completed event to the webhook URL, the handler doesn't verify the signature, and the database marks the account as paid.ATTACKERcurl POST/api/webhook witha fake eventHANDLERreq.json(), nosignature checkDATABASEplan = "pro"STRIPENever saw apaymentATTACKERcurl POST /api/webhook with a fake eventHANDLERreq.json(), no signature checkDATABASEplan = "pro"STRIPENever saw a payment

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."

Why AI-built apps skip signature verification

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.

How to add Stripe webhook signature verification in Next.js

  1. Read the raw body

    Use await req.text(), not req.json(). Stripe signs the exact bytes it sent.

  2. Verify the signature

    Call stripe.webhooks.constructEvent(body, signature, secret) with the Stripe-Signature header and your endpoint's whsec_ secret. Keep it and sk_live_ out of public env variables.

  3. Reject bad requests early

    Return 400 when verification fails, before touching the database.

  4. Remove test routes

    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
}
Stripe webhook signature verification: a real event versus a fake oneStripe sends a signed event, the handler verifies the signature with constructEvent and updates the plan. A fake event from a curl command has no valid signature, constructEvent throws, the handler returns 400 and the database is never touched.REAL STRIPE EVENTPOST event + Stripe-SignatureheaderconstructEvent: signature matchesupdate plan = pro200 receivedFAKE EVENT FROM CURLPOST fake event, no validsignatureconstructEvent throws400 Bad signaturenever touchedSENDERSenderStripe or anyoneROUTEYour handler/api/webhookDBDatabaseplan = proREAL STRIPE EVENTPOST event +Stripe-SignatureheaderconstructEvent: signaturematchesupdate plan =pro200 receivedFAKE EVENT FROM CURLPOST fakeevent, novalidsignatureconstructEvent throws400 Badsignaturenever touchedFROMSenderany callerROUTEHandler/api/webhookDBDatabaseplan = pro
With verification, a fake event stops at the handler.

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. A server action that sets the plan has the same problem, because every server action is a public endpoint.

Fix prompt you can paste

FIX PROMPT
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.

Further reading

  1. Stripe docs: Manage webhook endpoints (signatures, best practices)
  2. Stripe docs: Fulfill orders

FAQ

Why does Stripe signature verification fail in Next.js?

Usually because the body was parsed before verification. Read it with req.text() in an App Router route handler and pass that string to constructEvent unchanged. A wrong webhook secret is the other common cause.

Which webhook secret do I use locally?

The one stripe listen prints when it starts. Your Dashboard endpoint has a different whsec_ secret for production.

Is the success page enough to grant access?

No. Anyone can open the URL. Grant access from the verified webhook, or retrieve the Checkout Session on the server and check payment_status first.

Do Lemon Squeezy webhooks need verification too?

Yes. Lemon Squeezy signs the raw body with HMAC-SHA256 in the X-Signature header. Compute the same HMAC with your signing secret and compare with a timing-safe check.

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.