12 min read

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 literal

You 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 -50

Each 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 -100

Should 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