Stripe webhook security for AI-built apps: don't trust payment events without verification

Stripe Checkout can be added quickly to an AI-built app, but a weak webhook can mark users as paid without a real payment. Check these webhook security basics before launch.

Share
Stripe webhook security for AI-built apps: don't trust payment events without verification

Stripe Checkout is one of the fastest ways to add payments to a small app. That is exactly why it shows up in so many AI-built SaaS projects.

A builder asks Cursor, Lovable, Bolt, Replit, v0, or Claude Code to add subscriptions. A few minutes later there is a pricing page, a checkout button, and a webhook route that updates the user's plan after payment.

It feels done.

But payment code has a different failure mode from normal UI code. If a button is ugly, you can fix it later. If a webhook is wrong, a user may get a paid plan without paying, keep access after cancellation, or trigger business logic from a fake event.

The scary part is that a bad Stripe integration can pass a normal demo. You click the checkout button. Stripe opens. The success page loads. Your test account becomes Pro. Everything looks fine.

That does not prove the webhook is safe.

This checklist is for founders, freelancers, and small teams adding Stripe to AI-assisted apps. It focuses on the mistakes that matter before launch: webhook signatures, raw request bodies, plan changes, customer mapping, idempotency, secret keys, and testing.

If you want the broader pre-launch review, start with the full AI-built app security checklist before checking Stripe-specific webhook risks.

The short version

Before launch, check these Stripe webhook areas:

Check Why it matters
Webhook signature verification Fake events should not be accepted
Raw request body Stripe signature verification breaks if the body is parsed first
Server-side plan changes The browser should not decide who is paid
Customer ID mapping Email alone is not a safe account link
Subscription lifecycle Cancellations, failed payments, and renewals need handling
Idempotency Stripe may send the same event more than once
Checkout endpoint protection Users should not create arbitrary checkout sessions
Price ID validation The server should choose trusted price IDs
Secret key placement Stripe secret keys must never reach the browser
Safe logging Logs should not leak secrets or full payloads
Stripe CLI testing Real webhook tests catch mistakes that UI demos miss

1. Verify the webhook signature

This is the main rule. A production Stripe webhook should verify that the event really came from Stripe.

In Node or Next.js, you should see code using Stripe's signature verification flow:

const event = stripe.webhooks.constructEvent(
  rawBody,
  signature,
  webhookSecret
);

Be careful if your webhook route only does this:

const event = await request.json();

That parses JSON, but it does not prove the event came from Stripe.

A fake request can look like a real Stripe event. If your webhook trusts any JSON body that says checkout.session.completed, an attacker may be able to trick your app into upgrading an account.

What to check:

  • the route reads the stripe-signature header
  • the route uses your webhook signing secret
  • invalid signatures return an error
  • the app does not update plans before signature verification succeeds

2. Use the raw request body

Stripe signature verification needs the exact raw body that Stripe sent. If your framework parses or modifies the body first, verification can fail.

This is a common issue in generated code because AI tools often write normal JSON API routes first, then add webhook logic on top.

For Next.js App Router, the route usually needs something like:

const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');

Then pass rawBody into stripe.webhooks.constructEvent().

Do not parse the body first and then stringify it again. That is not the same payload.

Bad pattern:

const body = await request.json();
const event = stripe.webhooks.constructEvent(
  JSON.stringify(body),
  signature,
  webhookSecret
);

It may appear to work in a small test, then fail in production or hide the fact that the signature is not being checked correctly.

3. Never let the client change the plan directly

A paid plan should be granted by trusted server-side Stripe events, not by the browser.

Be suspicious of endpoints like:

POST /api/upgrade
POST /api/change-plan
POST /api/set-subscription

Especially if the request body looks like this:

{
  "plan": "pro",
  "paid": true
}

The frontend can ask to start checkout. It should not be able to say, "I paid, upgrade me."

A safer flow:

  1. User clicks upgrade.
  2. Server creates a Stripe Checkout Session with a trusted price ID.
  3. User pays on Stripe.
  4. Stripe sends a signed webhook event.
  5. Server verifies the signature.
  6. Server updates the user's plan based on the Stripe customer or subscription.

The browser should never be the authority on payment status.

4. Let the server choose the price ID

Do not accept arbitrary price IDs from the client.

Bad pattern:

const { priceId } = await request.json();

await stripe.checkout.sessions.create({
  line_items: [{ price: priceId, quantity: 1 }],
  mode: 'subscription',
});

If the client can choose any price ID, users may be able to start checkout for the wrong product, an old test price, or a cheaper plan that maps incorrectly in your app.

Better pattern:

const { plan } = await request.json();

const priceId = plan === 'agency'
  ? process.env.STRIPE_AGENCY_PRICE_ID
  : process.env.STRIPE_PRO_PRICE_ID;

The server should map a small list of allowed plan names to trusted Stripe price IDs.

Also check that test price IDs are not used in production.

5. Map customers by Stripe customer ID, not just email

Email is useful, but it should not be your only account link.

A better setup stores these values in your database:

stripe_customer_id
stripe_subscription_id
plan
subscription_status
current_period_end

When a webhook arrives, update the profile or organization linked to the Stripe customer ID or subscription ID.

Why email alone is risky:

  • users can change email addresses
  • multiple accounts can share billing contacts
  • Stripe events may contain customer emails that do not map cleanly to your app user
  • an attacker may try to exploit weak account matching

Email can help during initial checkout. Long term, Stripe IDs are the safer source of truth.

6. Handle more than checkout.session.completed

Many simple AI-generated Stripe integrations handle only one event:

checkout.session.completed

That is enough to upgrade a user after checkout. It is not enough to manage a real subscription.

For subscriptions, you should think about events like:

checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed

You do not need to build a complex billing system on day one, but you do need to decide what happens when:

  • a payment fails
  • a user cancels
  • a subscription is past due
  • a user upgrades or downgrades
  • a subscription is deleted
  • a trial ends

If your app upgrades users but never downgrades them, that is not a launch-ready billing flow.

7. Make webhook handling idempotent

Stripe can send the same event more than once. Your webhook should tolerate that.

That means a repeated event should not:

  • create duplicate records
  • send duplicate emails
  • double-count usage
  • create multiple subscriptions in your database
  • repeatedly overwrite state in a harmful way

A common pattern is to store processed Stripe event IDs:

stripe_event_id
processed_at

Before processing a new event, check whether that event ID has already been handled.

For very small apps, you may start simpler. But any webhook that triggers expensive or irreversible actions should be idempotent before launch.

8. Keep Stripe secret keys server-side

Your Stripe secret key should never appear in frontend code.

Search your production JavaScript and your repository for:

sk_live_
sk_test_
STRIPE_SECRET_KEY

A Stripe publishable key starts with pk_ and is meant for the browser. A Stripe secret key starts with sk_ and belongs only on the server.

Do not put secret keys in:

NEXT_PUBLIC_STRIPE_SECRET_KEY
frontend config files
client-side JavaScript
mobile app bundles
public GitHub repos
screenshots or logs

If a secret key was exposed, rotate it in Stripe.

9. Protect the checkout creation endpoint

The endpoint that creates Checkout Sessions should require a logged-in user unless your product is intentionally anonymous.

Check that:

  • the route verifies the current user
  • the route rate limits requests
  • the route creates checkout only for allowed plans
  • the route stores or passes a trusted user reference
  • success and cancel URLs are controlled by your app

A risky pattern is allowing the client to pass everything:

{
  "priceId": "price_...",
  "successUrl": "https://anywhere.com",
  "customerEmail": "[email protected]",
  "plan": "agency"
}

The server should decide most of that. The client should choose from a small set of allowed actions, not construct the whole payment session.

10. Do not trust success page redirects

A Stripe success URL means the user returned from checkout. It is not your final proof of payment.

Do not upgrade a user just because they landed on:

/dashboard?checkout=success

That URL can be visited manually.

Use the success page for UI messaging:

Payment received. Your subscription may take a moment to activate.

Use the signed webhook to update access.

11. Log enough to debug, not enough to leak

Webhook logs are useful. They can also leak sensitive data if you dump entire payloads into a logging service.

Avoid logging:

  • full event payloads in production
  • secret keys
  • raw request headers containing signatures
  • full customer billing details
  • full request bodies from users

Better logs:

received event type: customer.subscription.updated
event id: evt_...
customer id: cus_...
subscription id: sub_...
result: plan updated to pro

That is usually enough to debug without turning logs into a data leak.

12. Test with the Stripe CLI

Do not rely only on clicking through the UI.

Use the Stripe CLI to forward real test webhook events to your local app:

stripe listen --forward-to localhost:3000/api/stripe/webhook

Then trigger events:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

Check your database after each event.

Questions to answer:

  • Did the correct user get upgraded?
  • Did the app store the Stripe customer ID?
  • Did cancellation remove or downgrade access?
  • Did failed payment change the subscription status?
  • Did duplicate events cause duplicate records?
  • Did invalid signatures fail?

If you have never tested a cancellation, you do not know if cancellation works.

13. Check production and test mode separation

Stripe test mode and live mode use different keys, price IDs, customers, and events.

Before launch, check:

  • production uses sk_live_ only on the server
  • production uses pk_live_ in the browser
  • production webhook endpoint has the live webhook secret
  • production price IDs are live price IDs
  • test price IDs are not present in production env vars
  • live webhook events point to the production URL

This sounds obvious. It is still worth checking manually.

14. Make plan changes boring and explicit

Payment state should be boring. That is a compliment.

Avoid clever logic that guesses the user's plan from product names, email domains, URL parameters, or client-submitted values.

Use a clear mapping:

price_123 -> pro
price_456 -> agency

Store the mapping server-side. Keep it small. Review it when you change pricing.

Also store the subscription status separately from the plan. A user can have a plan name in your system while the Stripe subscription is trialing, active, past due, canceled, or incomplete.

15. Add one manual abuse test

Before launch, try to break your own payment flow.

You do not need to be a professional attacker. Just test the obvious things:

  • call the webhook route with fake JSON
  • visit the success URL manually
  • try to post plan: agency to your own API
  • try to create checkout while logged out
  • try to pass a different price ID
  • replay a webhook event
  • cancel a subscription and check access

If any of those gives paid access without a real, signed Stripe event, fix it before launch.

Example pre-launch Stripe test plan

Use this before publishing a paid AI-built app:

  1. Search the codebase for sk_live_, sk_test_, and STRIPE_SECRET_KEY.
  2. Confirm secret keys are server-only.
  3. Confirm checkout creation requires a logged-in user.
  4. Confirm the server chooses the price ID.
  5. Confirm the webhook uses stripe.webhooks.constructEvent().
  6. Confirm the route uses the raw body for signature verification.
  7. Test invalid webhook signatures.
  8. Test checkout completion with Stripe CLI.
  9. Test subscription cancellation.
  10. Test failed payment.
  11. Test duplicate event delivery.
  12. Check that plan changes are linked to Stripe customer or subscription IDs.
  13. Check logs for sensitive data.
  14. Verify live mode keys and price IDs before launch.

If you skip everything else, do the signature test and the cancellation test.

Common Stripe mistakes in AI-built apps

The same mistakes show up often:

  • webhook route parses JSON but never verifies the Stripe signature
  • app upgrades users from the success URL instead of the webhook
  • client can submit plan: pro or plan: agency
  • client can choose arbitrary Stripe price IDs
  • subscription cancellation is not handled
  • failed payments do not affect access
  • Stripe customer ID is not stored
  • email is used as the only account link
  • duplicate events create duplicate records
  • secret key appears in frontend code or public env variables
  • test price IDs are accidentally used in production

These are not advanced Stripe problems. They are launch checklist problems.

FAQ

Is Stripe Checkout secure by default?

Stripe Checkout itself is secure. Your integration can still be unsafe if your app trusts the browser, skips webhook signature verification, or updates plans from untrusted events.

Can I upgrade users on the success page?

Do not use the success page as proof of payment. A success URL is just a redirect. Use a signed Stripe webhook to update paid access.

Do I need to verify webhook signatures in test mode?

Yes. Test mode should behave like production. If signature verification is hard to test locally, use the Stripe CLI.

Should I store Stripe customer IDs?

Yes. Store the Stripe customer ID and subscription ID so webhook events can be mapped back to the correct account. Email alone is weaker.

What is the most dangerous Stripe webhook mistake?

Accepting webhook JSON without verifying the Stripe signature. That can let fake events trigger real account changes.

Final thought

Payments should be treated as a security boundary.

A working checkout button is not enough. A pretty pricing page is not enough. A success redirect is definitely not enough.

Before launch, make sure the server only grants paid access after a verified Stripe event. Everything else is just UI.