Your Stripe webhook endpoint is a public URL. Stripe sends events to it, but so can anyone else who finds it, and webhook URLs are not hard to guess: /api/webhook, /api/stripe/webhook, /functions/v1/stripe-webhook. If your code marks an order as paid, upgrades a plan or adds credits whenever a request arrives there, a fake request gets the same result as a real payment.
We find this in apps built quickly, and especially in apps generated by AI coding tools, which tend to produce a webhook handler that parses the JSON and acts on it. Here is how to make Stripe payments trustworthy in five steps.
1. Verify every webhook signature
Stripe signs every event it sends with your endpoint's signing secret (it starts with whsec_) and puts the signature in the Stripe-Signature header. Your handler must check that signature before it does anything else, and reject the request if it does not match. Stripe's libraries do the check for you, with one catch: they need the raw request body, exactly as it arrived. If your framework has already parsed the JSON, the check fails.
In a Next.js App Router route:
// app/api/stripe/webhook/route.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text(); // the raw body: parsing it first breaks the signature check
const signature = req.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object;
if (session.payment_status === "paid") {
await fulfillOrder(session.id); // safe to run twice, see step 4
}
}
return new Response("ok");
}
In the Pages Router, switch off Next.js's body parser for that route and read the raw body yourself:
// pages/api/stripe/webhook.js
export const config = { api: { bodyParser: false } };
async function rawBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
In a Supabase Edge Function (Deno), use the asynchronous version with the Web Crypto provider:
const event = await stripe.webhooks.constructEventAsync(
await req.text(),
req.headers.get("stripe-signature"),
Deno.env.get("STRIPE_WEBHOOK_SECRET"),
undefined,
Stripe.createSubtleCryptoProvider()
);
2. Never trust prices from the browser
The second classic mistake happens before the payment: the browser sends the amount, and the server creates a checkout for whatever it was told. Anyone can change that request in the browser console and buy your $500 plan for $1.
Create Checkout Sessions on the server, from your own product list, using Stripe Price IDs. The browser should only say what it wants to buy and how many:
// the browser sends { productId, quantity }; the price comes from your own data
const product = await getProduct(productId); // from your database, not from the request
const qty = Math.min(Math.max(parseInt(quantity, 10) || 1, 1), 10);
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: product.stripePriceId, quantity: qty }],
client_reference_id: user.id, // from your session, not from the request body
success_url: "https://your-app.com/thanks?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://your-app.com/pricing",
});
The same applies to discounts, plan names, credits and user IDs: everything that affects what someone pays or receives comes from your server.
3. Do not fulfil orders on the success page
The success_url is just a page. Anyone can open it, with or without paying, and a buyer who closes the tab too early never reaches it. Fulfil orders from the verified webhook. If you also want to show the result on the success page, retrieve the session on your server with stripe.checkout.sessions.retrieve(id) and check payment_status there, instead of trusting the visit.
4. Handle retries and duplicates
Stripe retries a webhook when your endpoint does not answer with a 2xx status, for up to three days in live mode, and the same event can occasionally arrive twice. Your handler has to be safe to run more than once:
- Store the IDs of the events you have processed and skip ones you have already seen, or make fulfilment idempotent (for example, an order that is already paid stays paid and does not get credits added twice).
- Answer quickly. Record the event, return 200, and do slow work such as sending emails in the background.
- Check that the event matches your records: the amount and currency are what the order expects, and the customer is who the order belongs to.
5. Keep the secrets secret
Your Stripe secret key (sk_live_), any restricted keys (rk_live_) and the webhook signing secret belong in server-side environment variables only, never in frontend code or a public repository. Use separate keys and webhook secrets for test and live mode. If a secret has leaked, roll it in the Stripe dashboard straight away. Our guide to exposed API keys explains how to check your site for leaked keys.
How to test it
The Stripe CLI can forward real, signed test events to your local app:
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
Then check the other side: a request without a valid signature must be refused. Against your own app:
curl -i -X POST https://your-app.com/api/stripe/webhook \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed"}'
You should get a 400 response and no change in your data. If you get a 200 and an order changes, anyone can do the same.
Get your payment flow checked
Payments are one of the areas we test in our AI app security audit, together with database rules, exposed keys, access between users and admin routes. You get a plain-English report with proof and fixes, and we can fix the issues for you and re-test. For the rest of the common problems in apps built with AI tools, see our pre-launch security checklist.



