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.
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-signatureheader - 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:
- User clicks upgrade.
- Server creates a Stripe Checkout Session with a trusted price ID.
- User pays on Stripe.
- Stripe sends a signed webhook event.
- Server verifies the signature.
- 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: agencyto 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:
- Search the codebase for
sk_live_,sk_test_, andSTRIPE_SECRET_KEY. - Confirm secret keys are server-only.
- Confirm checkout creation requires a logged-in user.
- Confirm the server chooses the price ID.
- Confirm the webhook uses
stripe.webhooks.constructEvent(). - Confirm the route uses the raw body for signature verification.
- Test invalid webhook signatures.
- Test checkout completion with Stripe CLI.
- Test subscription cancellation.
- Test failed payment.
- Test duplicate event delivery.
- Check that plan changes are linked to Stripe customer or subscription IDs.
- Check logs for sensitive data.
- 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: proorplan: 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.