In March a startup earned a Medium write-up the expensive way: a Stripe key shipped in its frontend code, roughly 175 customers affected and $2,500 in processing fees gone. That one made a story because the mistake was visible to anyone who viewed source. The six leaks below never make a story. They live in checkouts that charge real cards and pass every test the founder runs, while the product walks out the door unpaid: a revenue leak with no error message attached.

All six trace to one decision an AI tool makes on your behalf: letting the browser answer questions only the server can answer, like who paid, for what, and at what price. In 10 of the 21 third-party apps AxonBuild audited in June and July 2026, the server trusted the browser’s word on something only it should decide, prices and plans included. Each of those findings was verified against the code rather than pattern-matched.

The half of the checkout AI tools skip

A checkout is two jobs wearing one button. The first job is taking the card: the Stripe session, the redirect, the confirmation screen. That half is the one you watched work, and shipping it is genuinely further than most side projects ever get. The second job is deciding who is allowed in once money moves, and hearing about it when the money moves back. Sit through a vibe coding session that wires up a Stripe checkout and you’ll reliably watch the first job get finished and the second get sketched, because the demo only needs the first.

The second half is where checkout bugs turn into revenue leaks. A bug in the card half is loud: the charge fails, the customer emails you, Stripe’s dashboard shows red. A leak in the who-gets-in half makes no sound at all, because every request succeeds and the only thing wrong is who received the product. It’s the same misplaced trust boundary that runs through the security holes AI tools ship; a checkout is just the version with a price tag on it.

The six leaks

They run roughly from the webhook that announces a payment, through the access it unlocks, to the day the money goes back.

1 · The webhook nobody verifies

Stripe tells your server a payment finished by POSTing a checkout.session.completed event to a webhook URL. The generated handler often reads that JSON, sees "status": "paid", and grants access without checking that the request came from Stripe at all. A webhook URL is public. Anyone who finds it can POST a hand-written event claiming whatever they like, and the server upgrades an account no card ever paid for. Stripe signs every event so you can refuse forgeries, and its webhook docs walk through the check. In code, the difference between the two worlds is one line:

// Reject the request before reading a single field of it:
const event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret);

2 · The raw-body trap

Signature verification needs the exact bytes Stripe sent. Most frameworks helpfully parse the request into JSON before your handler runs, so what you verify no longer matches what was signed, and constructEvent throws on every legitimate event. A depressing number of tutorials fix that error by deleting the verification, which converts leak #2 straight back into leak #1. The fix that keeps the check is one route-level line:

app.post('/webhook', express.raw({ type: 'application/json' }), handleWebhook);

Stripe’s docs are explicit that any manipulation of the raw body makes verification fail. Every framework has an equivalent way to exempt one route from body parsing; the work is knowing to ask for it.

3 · Access granted in the browser

Here the unlock itself lives in client code. A component watches the redirect land, or reads a success flag, and writes the upgrade on the spot:

// This line runs entirely in the browser:
if (paymentSuccess) setUser({ plan: 'pro' })

Anything the browser can write, the browser’s owner can write. paymentSuccess is a variable sitting in a stranger’s dev tools, and flipping it costs nothing. The browser may ask whether an account has paid. The answer has to be written server-side, by the verified webhook, and nowhere else.

4 · Fulfilling on the success page

After payment, Stripe redirects the customer to your /success URL, and the generated page treats being rendered as proof of payment. This breaks in both directions at once. A paying customer whose connection drops before the redirect gets charged and receives nothing; Stripe’s fulfillment guide is blunt that customers aren’t guaranteed to ever reach that page, and that fulfillment belongs in the webhook handler. Meanwhile the page itself is just a URL, so a visitor who never paid can open it directly. Treat the success page as a receipt that reads the paid status your webhook already wrote, and give it no power to create one.

Until your server hears it from Stripe, a payment that unlocked in the browser is only a suggestion.

5 · The client sets the price

The checkout session gets created from numbers the browser sent: unit_amount: body.amount, or a priceId the client picked. In your own testing the client sends the right number every time, so the flow looks perfect. A stranger’s client can send any number, and client-side price manipulation needs nothing beyond dev tools: pay one dollar for the hundred-dollar plan, or pass a cheaper tier’s price ID and receive the expensive one.

I stopped filing this leak under theoretical when I audited a food-delivery app whose order total, line-item prices included, was copied straight from the browser’s cart and written to the database with no server-side recompute. A customer could record a one-cent total for a full cart of groceries. I still don’t know how long that path had been live, because the app kept no logs that could say. A B2B SaaS starter in the same corpus made the subtler version of the mistake: it passed the browser-chosen Stripe price ID through with no allow-list against its own plan catalog. Both checkouts demoed flawlessly, because a demo’s cart always sends the right numbers.

The fix is a standing rule: price lives on the server, in a fixed catalog keyed by plan. The browser names the plan it wants; the server decides what that plan costs.

6 · Cancel and refund that never revoke

Granting access is half of the wiring. The generated handler listens for the paid event and nothing else: no customer.subscription.deleted, no charge.refunded, no dispute events. Money can flow backwards; access never does. The same B2B starter from leak #5 had nothing at all listening for charge.refunded, so a customer could buy a token pack, refund the charge, and keep spending the credits. Retries bite here too: Stripe’s webhook docs note an endpoint can receive the same event more than once, so a handler that never checks event.id can grant the same purchase twice. Handle every event that changes payment state, revocations included, deduplicate on event.id, and where the handler calls Stripe in turn, use idempotency keys so a retried delivery can’t repeat the side effects.

Why testing it yourself proves nothing

Every leak above survives the test you’re most likely to run, which is buying your own product. When you test your checkout, you really pay. You follow the redirect. You send the honest price, you never POST forged events at your own webhook, and you certainly don’t refund yourself to see whether the credits survive. Your own test traffic is honest by default, and all six leaks need a dishonest customer, or merely an unlucky one whose connection dropped at the wrong second.

This is one gap out of the twelve that decide whether your app is actually ready to launch, and it’s the one that converts directly into money, because the person best positioned to find these six is the one person with a reason to keep quiet about them.

Fix it once, at the webhook

Look at the shape of the list. Five of the six are one mistake in different clothes: the browser was allowed to decide something only the server may decide. The sixth is a server that never listens for money leaving. That concentration is good news, because the fix concentrates too. One verified webhook, acting as the only code that writes or revokes the paid record, makes every downstream “is this account paid?” check trustworthy by construction. You never have to re-verify each page, because the pages all read from the one place that was wired right.

  1. 01 The webhook verifies the Stripe signature before trusting any field
  2. 02 Verification runs against the raw request body, on a route exempt from body parsing
  3. 03 Entitlement is written server-side by the webhook, never by client code
  4. 04 The success page shows a receipt and grants nothing
  5. 05 Price and plan come from a server-side catalog, never from the request
  6. 06 Cancel, refund, and chargeback each revoke access, and handlers deduplicate on event.id

Everything on that list is purchase-time wiring. What an account is entitled to can also drift long after a clean purchase, when renewals fail or subscriptions cancel and nothing downgrades; that entitlement-drift lane is a separate set of five checks, and this post stays on the purchase-time side. The scorecard will locate your weakest readiness area in about two minutes. And if you’d rather have this verified than self-assessed, you can have all six leaks checked against your actual webhook handlers in a Beyond the Demo Audit, each finding traced to a file and line.

Common questions

Is the success page proof of payment?

No. It fails as proof in both directions: a customer can pay and never reach the page (Stripe’s fulfillment guide names a dropped connection as the standard case), and someone who never paid can navigate to the URL directly. Proof of payment is the signed webhook event your server verified. The success page should read the result of that verification, and change nothing itself.

Can someone get paid access without paying?

In an AI-generated checkout, often yes, and without anything you’d call hacking. Four of the six leaks hand access to someone who never paid: a forged POST to an unverified webhook, a plan flag flipped in dev tools, a success URL visited directly, and a refund that takes the money back while access stays. None of them raises an error, which is why the Stripe dashboard can look healthy the whole time.