Skip Stripe webhook signature verification and anyone can fake a payment
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 })
}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
Read the raw body
Use
await req.text(), notreq.json(). Stripe signs the exact bytes it sent.Verify the signature
Call
stripe.webhooks.constructEvent(body, signature, secret)with theStripe-Signatureheader and your endpoint'swhsec_secret. Keep it andsk_live_out of public env variables.Reject bad requests early
Return 400 when verification fails, before touching the database.
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
}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
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
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 repoLeakcade 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