Skip to content
NewSecurity Audit for AI-Built Apps. Fixed scope, clear report
GeekTech
Vibe Coding

How to Secure Stripe Payments in an AI-Built App

How to secure Stripe payments in an AI-built app: set prices on the server, verify webhook signatures, fulfill each order once and lock down your API keys.

7 min read

GeekTech Engineering Team18 years building and securing software

To secure Stripe payments in an AI-built app, decide every price on the server, verify the signature on every webhook, and grant access only from verified payment data, with fulfillment that runs once per payment. Keep secret keys out of code and the browser, and use restricted keys where you can. Then test each control by trying to break it.

A prompt like "add Stripe checkout" in Cursor, Lovable, Bolt or Replit produces a working payment flow in minutes. The security gaps sit in requests nobody sends through the UI.

Where AI-generated Stripe code goes wrong

Weak spotWhat can happenWhere to look
Amount or price sent from the browserA user pays less than you intendedThe route that creates the Checkout Session
Unverified webhook endpointAnyone can post a fake "payment succeeded" eventThe webhook route
Access granted on the success pageUsers unlock paid features without payingThe page behind success_url
Fulfillment without deduplicationDuplicate orders, emails or creditsThe function that grants access
Subscription status set only at signupCanceled or refunded customers keep accessSubscription and charge event handlers
Secret key in code or the client bundleUnauthorized charges, exposed customer dataRepository, environment variables, built JavaScript

The last row is the leak covered in our list of security mistakes in apps built with AI tools.

Set the price on the server, never in the browser

Generated checkout code can send the amount or price ID from a React component to an API route that passes it straight to Stripe. Anyone with developer tools can edit that request.

The server should decide what the customer pays:

  • The client sends a plan key, such as pro_monthly. The server maps it to a Stripe Price ID from a fixed allowlist and rejects anything else.
  • Never accept a raw amount. If the total depends on a cart, recompute it from your own product records.
  • Reject negative, zero or very large quantities before they reach Stripe.
  • Take the user ID for client_reference_id or metadata from the server-side session, not the request body. Otherwise one user can attach a payment to someone else's account.

Even a real price ID from the client is a risk: old prices and internal plans can still be active.

Verify every Stripe webhook signature

The webhook endpoint is a public URL. Stripe's webhook documentation states the risk plainly: without verification, an attacker can send fake events to trigger fulfillment, grant account access or modify records.

Verification needs the Stripe-Signature header, the endpoint's whsec_ signing secret and the raw request body. A framework that parses the body as JSON first makes verification fail, and an AI tool asked to fix that error may simply delete the check. In a Next.js App Router route handler, read the body as text:

// app/api/stripe/webhook/route.ts
export async function POST(request: Request) {
  const payload = await request.text();
  const signature = request.headers.get("stripe-signature") ?? "";
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  await enqueue(event); // do the slow work after acknowledging
  return new Response(null, { status: 200 });
}

In the Pages Router, set bodyParser: false for that route. In Express, mount express.raw({ type: "application/json" }) on it instead of express.json().

Related details from the same documentation:

  • The signed payload includes a timestamp, and Stripe's libraries reject events older than five minutes by default. A tolerance of 0 disables that check.
  • One endpoint has different signing secrets for sandbox and live mode.
  • Return a 2xx quickly. In live mode, Stripe retries failed deliveries for up to three days.
  • Subscribe the endpoint only to event types you handle, and consider allowlisting Stripe's published webhook IP addresses.

Fulfill orders from webhooks, exactly once

One generated pattern marks an order as paid when the browser lands on /success?paid=true. Anyone can type that URL.

Stripe's Checkout fulfillment guide says webhooks are required, because customers aren't guaranteed to reach the landing page after paying. It recommends one fulfillment function, called from both the webhook handler and the landing page, that:

  1. Takes a Checkout Session ID, never a "paid" flag.
  2. Retrieves the session from the Stripe API and checks payment_status.
  3. Grants what was bought, based on the line items Stripe returns.
  4. Records that the session has been fulfilled.

The guide warns that this function can run several times, possibly concurrently, for one session, and webhook endpoints can receive the same event twice. Enforce "once" in the database: a fulfillments table with a unique constraint on the session ID, written in the same transaction that grants access. A second attempt hits the constraint and stops.

Delayed payment methods such as bank debits complete Checkout before the money arrives. Handle checkout.session.async_payment_succeeded and checkout.session.async_payment_failed too.

Keep subscription access in sync with Stripe

Generated SaaS code can set plan = 'pro' at checkout and never touch it again. Customers who cancel, stop paying or get a refund keep access.

Treat Stripe as the source of truth and update your copy from events:

  • customer.subscription.updated and customer.subscription.deleted for plan changes and cancellations.
  • invoice.paid and invoice.payment_failed for renewals and failed payments.
  • charge.refunded and charge.dispute.created, so refunds and disputes trigger your policy.

Stripe doesn't guarantee event order. To make order irrelevant, fetch the current subscription from the API on each event and store that state, instead of applying the payload as a change.

Users must not be able to write those fields. In Supabase apps, check that no update policy lets a user edit plan or subscription_status on their own profile row, one of the Supabase row-level security mistakes we cover separately.

Keep Stripe keys out of code and limit what they can do

Stripe's API key best practices set the baseline. Publishable keys are safe in webpages and apps. Secret keys stay on the server, out of source code, supplied by a secrets manager or environment variables, with a secret scanner in a pre-commit hook.

Two controls limit the damage if a key leaks anyway:

  • Restricted API keys give each component only the permissions it needs, such as a reporting job that can read charges and nothing else.
  • IP restrictions block requests made with your keys from anywhere but your servers, if they call Stripe from stable addresses.

If a secret or restricted key was exposed anywhere, Stripe's guidance is to rotate it immediately, even if you aren't sure anyone saw it. AI coding agents should only ever see sandbox keys.

Common mistakes in AI-generated payment code

  • Removing signature verification to get past an error during local testing.
  • Catching every error in the webhook handler and returning 200, so failed fulfillment never retries.
  • Logging full webhook payloads, which can include customer names and emails, to a third-party logging service.
  • Hard-coding sandbox Price IDs that fail in live mode, then "fixing" it by accepting price IDs from the client.

A checklist to secure Stripe payments before launch

Run these in a sandbox with two test accounts:

  1. Edit the checkout request in developer tools to change the price ID, amount or quantity. The server rejects or ignores the change.
  2. Send an unsigned POST to the webhook URL. It returns 400 and nothing changes.
  3. Forward events with stripe listen, then replay a completed checkout with stripe events resend. Access is granted once.
  4. Open the success URL with another user's session ID, or an unpaid one. No access changes.
  5. Cancel a test subscription and refund a test charge. Access changes as your policy says.
  6. Search the built JavaScript and the repository for sk_live_, sk_test_, rk_live_, rk_test_ and whsec_. Nothing turns up.
  7. List the event types the endpoint receives. Each one has a handler.

When an AI tool later changes these files, read that diff carefully, as described in our guide to reviewing AI-generated code before you merge.

If your app already takes payments and nobody has reviewed the flow, our security audit for AI-built apps covers payment and personal-data flows, with a written report and a prioritized fix list. To check on your own first, use our security checklist for AI-built apps.

Frequently asked questions

Yes, if anything happens automatically after payment. Stripe's fulfillment guide says you can't rely on the landing page alone, and that webhooks are required for subscriptions and delayed payment methods. Payment Links can redirect to your own landing page, and the same rule applies.

How do I test Stripe webhooks locally?

Use the Stripe CLI. stripe listen --forward-to localhost:3000/api/stripe/webhook forwards sandbox events to your local server and prints a signing secret for local use. Keep verification on while testing.

What should I do if a Stripe secret key was committed to Git?

Rotate it in the Stripe Dashboard and deploy the new key. Deleting the commit doesn't help, because the key may already have been copied from history, forks or CI logs. Then review your API request logs for activity you don't recognize.

Insights

Related insights

More on Vibe Coding →

Get AI security and automation insights, monthly

AI security, automation and governance, written by engineers. No spam, unsubscribe any time.

Book a free 30-min call