Security Basics
9 min
A secure Stripe payment integration is not achieved by swapping a test key for a live key and watching the first payment succeed. That proves the happy path works. It does not prove a customer cannot alter a price in the browser, replay a webhook, claim access to another account’s purchase, or trigger fulfilment without a confirmed payment.
For founders shipping quickly with React, Next.js, Supabase and AI-assisted code, payment logic is often assembled from snippets that look credible but leave critical decisions in the client. The result can be lost revenue, fraudulent upgrades, support problems and exposed customer data. Stripe provides strong primitives. Your application still has to use them in the right places.
Secure Stripe payment integration starts on the server
The browser should never be the authority for what a customer is buying or what it costs. A checkout button can send a product identifier, a selected plan or a quantity to your backend. Your backend must then look up the permitted price from its own configuration or database and create the Stripe Checkout Session or Payment Intent using that trusted value.
A common weakness looks harmless: the frontend sends price: 19.00, plan: "pro", or an arbitrary Stripe Price ID to an API route, and the route passes it through to Stripe. A user can alter that request with browser developer tools or a proxy. If the backend accepts it, they may pay less than intended or select a plan they should not be offered.
The fix is specific. Keep a server-side allowlist that maps your internal plan codes to Stripe Price IDs, validate quantity limits, and calculate any discounts on the server. If you support marketplace payments, credits or usage billing, treat every amount and destination account as untrusted input until it has been checked against your own records.
Your Stripe secret key belongs only in server-side environment variables. It must not appear in a JavaScript bundle, mobile client, public configuration endpoint, repository history, build log or error-tracking event. Publishable keys are designed for the client. Secret keys, restricted keys and webhook signing secrets are not.
AI-generated applications can blur this boundary. It is worth searching the repository for sk_, rk_, whsec_, STRIPE_SECRET, hard-coded environment files and API routes that return configuration objects. Rotating a leaked key matters, but it is only half the job. Find the route, build process or repository permission that exposed it before issuing a replacement.
Do not grant access from a success page
Stripe can redirect a customer to a success URL after Checkout. That page is useful for confirmation, but it is not proof that your database should upgrade an account. A user can visit or refresh the URL directly. Payment methods can also be delayed, fail after initial processing, be disputed or require additional action.
Your application should grant paid access, create an order or release fulfilment only after a verified server-to-server webhook confirms the relevant Stripe event. For a subscription product, the exact events depend on your billing design, but the principle does not: Stripe’s event is the source for payment state, while your database records the entitlement your app enforces.
Do not use a request parameter such as ?paid=true, a client-side local storage flag or a frontend response from Stripe as the final decision. These are interface signals, not authorisation controls.
Verify webhook signatures before reading the event
A webhook endpoint is an internet-facing API. Anyone can send a POST request to it unless you verify Stripe’s signature using the endpoint’s webhook signing secret and the raw request body. Verification must happen before you act on the event data.
Framework defaults frequently cause mistakes here. Some body parsers parse JSON before signature verification, which changes the raw payload and makes verification fail. Developers then remove the check to get the integration working. That is an operational shortcut with a direct fraud path.
Configure the route to preserve the raw body for Stripe verification. Reject invalid signatures, use a separate signing secret for each environment, and log enough detail to investigate failures without storing payment data you do not need. Your test endpoint secret is not interchangeable with your live endpoint secret.
Then make webhook processing idempotent. Stripe may retry an event when your endpoint times out or returns an error, and duplicate delivery is normal. Store Stripe event IDs and ensure processing the same event twice cannot create two orders, issue two credits or run fulfilment again. Database-level uniqueness constraints are stronger than relying on an in-memory check.
Connect Stripe customers to the right user
A valid payment does not automatically tell your application which logged-in user should receive access. That mapping is where account compromise and IDOR issues can enter the flow.
When creating a Checkout Session, associate it with a user identity that your server has established from the authenticated session. Store the relationship in your database and, where useful, include a non-sensitive internal reference in Stripe metadata or client_reference_id. Do not accept a user ID from the browser and attach a purchase to it without checking that it matches the current authenticated user.
After a webhook arrives, retrieve the expected order or subscription by a trusted Stripe object ID, then apply the change to the mapped account. If a user asks to view invoices, cancel a subscription or open a billing portal, verify they own the customer record first. A route such as /api/billing/customer?userId=... is a warning sign if changing the ID could expose another customer’s billing data.
For Supabase applications, do not rely solely on the frontend hiding billing records. Enable row-level security on payment-related tables and write policies that restrict reads and updates to the authenticated owner. Service-role access should remain in backend functions only. A service-role key in the client can bypass RLS entirely, turning a billing feature into a database exposure.
Treat subscriptions and entitlements as separate systems
Stripe manages the financial lifecycle. Your application manages what the customer can do. Those are connected, but they should not be the same field casually overwritten by whichever request arrives last.
Maintain a clear entitlement model: the account has a plan, access status and expiry or renewal information derived from verified Stripe events. Handle cancellation, payment failure, past-due status, refunds and disputes deliberately. Whether access ends immediately on a failed payment or after a grace period is a product decision. It should be implemented consistently, not left to an old success-page check.
This matters when events arrive out of order or when a customer changes plan. Record the Stripe subscription and customer IDs, track event timestamps where relevant, and avoid downgrading an account because a stale event was processed after a newer one. For high-value actions, retrieve the current object from Stripe when your local state is uncertain rather than guessing from a partial event payload.
Reduce the surrounding attack surface
The payment endpoint is not the only thing worth reviewing. Checkout API routes need authentication, rate limiting where abuse is possible, input validation and useful audit logs. Your production domain should use HTTPS, and security headers such as CSP and HSTS reduce exposure around the wider application.
Keep dependencies current, especially payment SDKs and authentication libraries. Check that staging, preview and production environments use distinct Stripe keys and webhook secrets. Preview deployments accidentally connected to live Stripe are a recurring source of test orders, leaked logs and confusing customer records.
A focused review should be able to answer four questions:
Can a customer change a product, price, quantity or recipient before checkout is created?
Can anyone forge, replay or duplicate a webhook to trigger a paid action?
Can one authenticated user access another user’s customer, order or subscription data?
Can secret keys or webhook secrets reach a browser, repository, log or public endpoint?
If any answer is uncertain, the integration needs investigation before more customers rely on it. Payment code is often short, but it sits at the point where identity, money and access control meet.
HollowByte reviews live applications and repositories for these failures alongside exposed credentials, IDOR, RLS gaps, JWT handling and insecure API routes. The useful outcome is not a generic warning that payments are risky. It is a severity-rated finding tied to the affected route, configuration or file, with the exact change needed to close it.
Your checkout can remain fast. The security decision-making just needs to stay on systems your customer cannot edit.
Back to blog
