Browse the blog
Sentrint Blog
AI-built apps · Vulnerabilities

Your paywall is a CSS rule. Here is what it costs and how to fix it

Ask an AI tool for a premium feature and you get a conditional render, which hides the button and leaves the endpoint open. Anyone can call it directly. The fix is a plan check inside the handler that reads the plan from your database rather than from the request, plus a webhook that verifies its own signature.

Of all the flaws in AI-built apps, this is the one with the clearest price attached. A missing authorisation check might cost you a breach; a missing plan check costs you the difference between your free tier and your paid one, every month, silently, from anyone who thought to look.

It is also the flaw people are least likely to test for, because testing it means acting like a customer trying to cheat, and most founders do not think that way about their own product. Some fraction of your users will, and it only takes one of them posting the trick somewhere public.

What does the generated version look like?

You asked for something like "only show the export button to paid users", and the model did exactly that. The component checks a plan value it already has and renders the button or does not.

{user.plan === 'pro' && <ExportButton />}

That line is correct and it is not a paywall. It controls what appears on a screen, and the endpoint the button calls is a separate file with its own rules, which in a generated app usually means a session check and nothing else. A free user who opens the network tab, finds /api/export, and calls it gets the export.

The reason this happens is that your instruction was complete in the way instructions usually are. "Only show it to paid users" describes an interface. Nothing in it says "and refuse the request", because to a person those are obviously the same thing and to a code generator they are two files.

The four ways it is actually bypassed

Calling the endpoint directly. The lowest-effort version. Open developer tools on a paid account or read your own JavaScript bundle, find the route, call it. No tooling beyond a browser.

Deleting the condition in the browser. React state is editable, and for apps where the gate is purely visual, changing plan in a component's state renders the premium view with real data in it. If the data was already fetched before the check, it was in the browser the whole time.

Sending the plan in the request. Some generated code accepts the plan as a parameter, because that was the shape of the function when it was written. A request body containing "plan": "pro" should be ignored, and often is not.

Never actually paying. If your checkout marks the account paid on the client's redirect back from Stripe rather than on the webhook, then reaching the success URL is enough. The redirect is a browser navigation and anyone can perform it.

That last one is the expensive variant, and it is common in generated code because the redirect handler is easy to write and the webhook is not. It also fails quietly, since the account reads as paid in your own database and no charge ever arrives.

How do I test my own?

Fifteen minutes with two accounts, one free and one paid:

  1. As the paid account, use every premium feature with the network tab open and write down each request: method, path, body. This is your list.
  2. As the free account, replay each one. The network tab in Chrome and Firefox both offer copy as fetch, which you can paste into the console.
  3. Read what comes back. A 402 or 403 is correct. A 200 with data is a bypass, and so is a 500, because a 500 means the check never ran and something further in failed.
  4. Then check the money side. Look at how an account becomes paid in your code. If any path to that sets the plan without a verified webhook, walk that path with a free account and see where you land.

Test the quota logic too, if you have one. A limit enforced by disabling the button after ten uses is the same class of mistake, and it is usually written by the same prompt.

How is it fixed properly?

One rule covers most of it. The server decides, and it decides from your own records rather than from anything the browser sent it.

Every handler that serves something premium reads the current user's plan from your own records, at request time, and refuses if the plan is wrong. Not from the request body, not from a JWT claim written at login three weeks ago, not from a header. From the row.

const { plan } = await db.users.findById(session.userId)
if (plan !== 'pro') return res.status(403).json({ error: 'upgrade required' })

Put that check in the handler rather than in middleware. Middleware is convenient and it is a filter you can misconfigure with a matcher, and the handler is the thing that actually touches the data.

For the billing half, only your webhook may change a plan. Verify its signature with the provider's library, use the event to update the row, and treat the browser redirect as what it is, which is a page to look at while the webhook does the work. Make the handler idempotent as well, since providers retry and a double credit is its own kind of bug.

Keep the interface condition too. Hiding the button is still right; it is just doing a different job, which is telling paid users what they have rather than stopping anyone.

Where else the same mistake lives

Once you have seen the shape, it turns up in places that have nothing to do with billing. An admin panel hidden from the nav but reachable by URL. A feature flag evaluated in the browser. A rate limit implemented by disabling a button. An early access gate that only checks on the client.

All of them are the same error: a decision made where the user can change it. Finding them means reading the code rather than clicking through the product, and a repository has more surface than an evening. That is the job Sentrint does: it reads the whole thing and reports what an attacker would reach first, in plain English, with a fix you can paste back into the tool that wrote it. The first scan is free and needs no card.

Do the fifteen minute test tonight regardless. It is the only flaw on our list where you can put a number on what it has already cost you.