How to add Stripe to your Next.js app (with the bugs everyone ships)
Adding Stripe to a Next.js app is a 30-minute task. Doing it without shipping a webhook-bypass bug, a leaked secret key, or an unsigned-event vulnerability takes another 30 minutes. Here is the real walkthrough.
You're charging users. You picked Stripe. You asked your AI assistant for an integration. The code compiles. The test card works.
Half of those integrations ship with a bug that lets attackers fake payment events, never log a webhook, or — most embarrassingly — leak the secret key in source. Here is the real walkthrough that handles all three.
The 30-minute working setup
Install + create the API route + create the webhook handler:
npm install stripe```ts // lib/stripe/server.ts import "server-only"; import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) { throw new Error("STRIPE_SECRET_KEY required"); }
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2025-10-15.basil", }); ```
```ts // app/api/checkout/route.ts (creates a checkout session) import "server-only"; import { stripe } from "@/lib/stripe/server"; import { createClient } from "@/lib/supabase/server";
export async function POST(req: Request) { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response(null, { status: 401 });
const { priceId } = await req.json(); if (typeof priceId !== "string") { return new Response("invalid", { status: 400 }); }
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: ${process.env.NEXT_PUBLIC_APP_URL}/app?checkout=success,
cancel_url: ${process.env.NEXT_PUBLIC_APP_URL}/pricing?checkout=cancel,
customer_email: user.email,
client_reference_id: user.id, // critical — links Stripe customer back to your user
metadata: { user_id: user.id },
});
return Response.json({ url: session.url }); } ```
```ts // app/api/webhooks/stripe/route.ts (handles webhook events) import "server-only"; import { stripe } from "@/lib/stripe/server"; import { headers } from "next/headers";
export async function POST(req: Request) { const body = await req.text(); // raw body for signature verification const sig = (await headers()).get("stripe-signature"); if (!sig) return new Response("missing signature", { status: 400 });
let event: import("stripe").Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (e) {
return new Response(bad signature: ${e}, { status: 400 });
}
// Now event is verified — safe to act on. switch (event.type) { case "checkout.session.completed": { const session = event.data.object; const userId = session.client_reference_id; // ... mark user as paid in your database break; } case "customer.subscription.deleted": { // ... mark user as unsubscribed break; } // ... handle other events you care about }
return Response.json({ received: true }); } ```
That's the baseline. Each line below explains why it has to be exactly that way.
The 4 bugs that ship with most Stripe integrations
### Bug 1 — webhook handler does NOT verify signatures
The most common Stripe-integration bug. The AI generates:
// WRONG
export async function POST(req: Request) {
const event = await req.json(); // <-- just parses the JSON, doesn't verify
if (event.type === "checkout.session.completed") {
// mark user as paid
}
return Response.json({ ok: true });
}An attacker who finds your webhook URL (it's discoverable — Stripe's webhook URL is in your dashboard, sometimes in your codebase, sometimes leaked in a logger) can POST a fake checkout.session.completed event with any user_id and grant themselves the paid plan.
The fix is the stripe.webhooks.constructEvent(body, sig, secret) call above. It verifies the HMAC signature against your webhook secret. If the signature doesn't match, the request is rejected.
Two requirements for the verification to work:
1. req.text() (raw body), NOT req.json() — the signature is over the raw bytes
2. The webhook secret (STRIPE_WEBHOOK_SECRET) configured in your env, matching the one Stripe shows in the webhook dashboard
### Bug 2 — secret key in source
You ask Cursor to set up Stripe. Cursor wants to test it works, so it inlines the test secret key:
// WRONG
export const stripe = new Stripe("sk_test_51XYZ..."); // <-- inline literalYou commit. The key is in git history forever. If your repo is public — even briefly — the test key is in scrapers' pools. Stripe test keys can issue real stripe.customers.list() and similar; an attacker can scrape your customers' emails.
The fix is the process.env.STRIPE_SECRET_KEY! pattern with a hard error if missing. Even with the env var, double-check git history:
git log -p --all -S 'sk_live_' | head -50
git log -p --all -S 'sk_test_' | head -50Each match is a leak. Rotate in Stripe (dashboard → developers → API keys → roll key) and update your env vars.
### Bug 3 — trusting client-supplied amounts
You build a checkout endpoint that takes the amount from the request body:
// WRONG
const { amount } = await req.json(); // <-- attacker controls this
const session = await stripe.checkout.sessions.create({
line_items: [{ price_data: { currency: "usd", product_data: { name: "...", }, unit_amount: amount } }],
// ...
});User opens dev tools, modifies the request, sets amount=1 (1 cent). They get the product for 1 cent.
The fix is the priceId pattern in the working setup above. You define prices in the Stripe dashboard (or as fixed constants in your code); the request specifies WHICH price, not how much it costs. Server-side resolution from priceId to actual amount.
// RIGHT
const PRICE_IDS = ["price_indie_12", "price_solo_49", "price_startup_299"];
const { priceId } = await req.json();
if (!PRICE_IDS.includes(priceId)) {
return new Response("invalid price", { status: 400 });
}
const session = await stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity: 1 }],
// ...
});### Bug 4 — webhook idempotency missing
Stripe sends webhook events with at-least-once delivery. The same event can arrive twice (Stripe retries on transient failures). If your handler doesn't check for duplicates, a duplicate checkout.session.completed event gives the same user TWO subscriptions credited.
The fix is to dedupe by event ID:
```ts // In your webhook handler const supabase = createAdminClient(); const { data: alreadyHandled } = await supabase .from("stripe_webhook_events") .select("id") .eq("id", event.id) .single();
if (alreadyHandled) { return Response.json({ received: true, duplicate: true }); }
// ... handle the event ...
await supabase.from("stripe_webhook_events").insert({ id: event.id, type: event.type, processed_at: new Date().toISOString(), }); ```
The stripe_webhook_events table needs a unique constraint on id and a primary-key insert; if a concurrent duplicate arrives, the second insert fails and you skip processing.
Test it works
After you wire up Stripe, run these tests:
### Test 1 — unsigned webhook
curl -X POST https://your-app.com/api/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed","data":{}}'Should return 400 ("missing signature" or "bad signature"). If it returns 200, your webhook is unsigned.
### Test 2 — modified amount
Open the checkout flow in your app. Open dev tools, intercept the /api/checkout request, modify the request body to a different priceId or to inject unit_amount: 1. Submit. The server should reject the request or return 400.
### Test 3 — secret in source
git log -p --all -S 'sk_live_' -S 'sk_test_' | head -100Should be empty. Any matches are leaks.
### Test 4 — duplicate webhook
Send the same Stripe event twice (use Stripe CLI's stripe trigger twice). Verify the user gets credited only once.
Stop checking these manually
Securie covers all four of these bug classes on every PR:
- The webhook-verification specialist detects POST handlers with no signature check
- The secret_scanner specialist catches Stripe keys in source + live-validates against the Stripe API + opens an auto-rotation PR (Indie tier and up)
- The auth_authz specialist catches client-controlled-amount patterns in checkout endpoints
- The mass-assignment specialist catches webhook handlers that trust event payloads without dedupe
Free during early access. 2-minute install. The Stripe-integration bug class is the single most-shipped class for solo founders adding payments; automated review catches it structurally.
Related
Related posts
Most auth tutorials show you how to add a login button. This is the guide that shows you how to add auth that actually works — what to wire up, what AI tools get wrong, and the bugs you ship if you copy-paste the first Stack Overflow answer.
If you sell internationally, the boring tax + compliance work eats your time. Lemon Squeezy and Paddle become Merchant of Record, handling sales tax + VAT in 60+ countries. Stripe stays the platform but pushes the tax work back to you. Here is the honest comparison for solo founders.
Every AI-generated Next.js app ships with middleware.ts that looks like it gates admin routes. Half of them do not actually run on the routes they think they run on. Here is the 5-minute test, the canonical bugs, and the fixes — written for solo founders who do not want to read the matcher RFC.
It's 3 AM. You scrolled X and saw a tweet about a Lovable / Bolt / v0 app leaking customer data. You start wondering if yours is next. Here is the exact checklist to run in the next 30 minutes — what to check, what to fix first, and how to stop having this problem.