Stripe itself is secure. The risk lives in your integration: how your app receives Stripe's events, how it decides what was paid for, and how it grants access. Three ideas drive the whole checklist — never trust the client for anything money-related, treat the webhook as the source of truth, and assume every event can arrive more than once.
1. Verify the webhook signature against the raw body
The most common — and most dangerous — Stripe bug is a webhook handler that parses the JSON body before verifying the signature, or skips verification entirely. Without verification, anyone who knows your endpoint URL can POST a fake "payment succeeded" event and unlock paid features for free.
- Read the raw request body and call stripe.webhooks.constructEvent with your signing secret.
- In Next.js App Router, read req.text() — not req.json() — so the body isn't re-serialized before verification.
- Reject anything that fails verification with a 400, and never act on an unverified event.
2. Make webhook handling idempotent
Stripe will retry events, and the same event can legitimately arrive more than once. If your handler isn't idempotent, a retry can grant access twice, send duplicate emails, or double-apply credits.
- Record processed event IDs and ignore an event you've already handled.
- Make the side effects safe to repeat (upserts, not blind inserts).
- Use Stripe idempotency keys on the requests you send, too, so retried API calls don't duplicate charges.
3. Resolve price and plan on the server
Never let the browser tell you what was purchased or how much it cost. AI-generated checkout code often reads an amount, price ID, quantity or coupon from request data — which a user can tamper with. Derive everything from Stripe's own objects.
- Create Checkout Sessions and Payment Intents with prices resolved on the server from your catalog.
- On webhook receipt, read the purchased plan from the Stripe line items, not from client metadata.
- Treat amount, currency, quantity and discounts as server-owned values.
Scan your billing code for unverified webhooks and client-controlled pricing.
Check your Stripe integration4. Gate entitlements on verified state
Access to paid features should be derived from billing state you trust — your database, updated by verified webhooks — not from a client flag or a success-page redirect. A user landing on /success doesn't prove a payment cleared.
- Grant access based on subscription/payment state written by verified webhooks.
- Don't unlock features purely because the browser reached the success URL.
- Handle the unhappy paths: failed payments, disputes, cancellations and refunds.
5. Keep keys and modes straight
- The secret key (sk_live_) is server-only; only the publishable key belongs in the client.
- Keep the webhook signing secret server-side and rotate it if it ever leaks.
- Confirm you're not shipping test keys to production — or live keys into a test environment.
6. Reconcile refunds, disputes and cancellations
The happy path — payment succeeds, access granted — is the easy part. Real billing has to handle money flowing the other way: refunds, chargebacks, failed renewals and cancellations. AI-generated integrations almost always implement the upgrade and forget the downgrade, which means a refunded or cancelled customer keeps paid access.
- Handle charge.refunded, customer.subscription.deleted and dispute events, not just the success events.
- Revoke or downgrade entitlements when a subscription ends or a payment is reversed.
- On failed renewals (invoice.payment_failed), follow your dunning logic instead of cutting access instantly or never.
- Keep a record of why each entitlement change happened, so billing disputes are auditable.
Payments reward paranoia. If you verify webhooks on the raw body, make handlers idempotent, resolve pricing on the server, and gate access on verified state, you've closed the bugs that actually cost money. Everything else in billing is a refinement on top of those four.
Get a launch-readiness report that includes your Stripe and webhook handling.
Scan your repo for free