A SaaS subscription entitlement bypass starts in the browser
The concrete risk: a free user opens your app, edits one value the browser is already holding, and the paid features unlock. No payment, no Stripe event, no server involved. This is a subscription entitlement bypass, and it is one of the most common ways an AI-built SaaS leaks its paid product on day one.
It happens when the decision "is this user allowed to use the Pro feature?" is made on the client. The app fetches the user, finds something like plan: free or isPro: false, and hides the upgrade-gated UI based on that flag. The flag lives in localStorage, in a JavaScript variable, in a React/Vue store, or in a cookie the browser can read. All of those are editable by the person sitting at the keyboard.
Separate what is observable from what is only possible here. What is observable is the gate itself: open DevTools and you can see whether the paywall is a client-side conditional. What is possible, and what the bypass exploits, is that flipping the flag also makes the underlying paid action succeed because the server never re-checks it. If the server does re-check, flipping the flag only changes what the UI draws, and the request still fails. The whole question is which of those two your app does.
This is an authorization failure, not an authentication one. The user may be fully logged in and genuinely who they say they are. The broken part is what they are allowed to do once authenticated. In OWASP terms it sits in Broken Access Control; the CWE class is missing authorization on a function (the paid feature) that should require an active subscription.
Why AI-built SaaS apps land here
Ask an AI builder to "add a Pro plan that unlocks the export feature" and you reliably get a working demo: a plan field on the user, a conditional that shows the export button when plan is pro, and a checkout flow that sets the field after payment. It looks complete because every visible thing behaves correctly when you click through it as the intended user.
What the generated flow usually skips is the second gate. The export button is hidden for free users, so it feels protected, but the export endpoint behind the button often runs for anyone who calls it. The model optimized for the happy path you described, not for the request a free user can hand-craft. Hiding UI is presentation; refusing the request is authorization, and the two get conflated.
- The visible paywall and the server check are two different jobs. AI scaffolding tends to produce the first and assume the second.
- The plan flag is fetched to the client so the UI can render, which makes it look like the source of truth when it is only a display copy.
- Fast deploy means the public URL exists before anyone traces a paid request end to end to confirm the server says no.
- The same gap shows up behind admin features. If a route is gated only by hidden UI, see the note on exposed admin routes in AI-built apps for the route-level version of this problem.
Check your own gate from the outside
You can confirm how your gate behaves on your own app, with your own accounts, without attacking anyone. The goal is to see whether the server decides access or just the browser.
Self-check one, edit the client flag. Log in as a free test account, open DevTools, and find where the plan is stored: the application tab for localStorage and cookies, or the network response that returns your user object. Change the value that represents your plan to the paid one and trigger a paid feature.
Self-check two, call the endpoint directly. In the Network tab, find the request the paid feature sends (for example POST /api/export or GET /api/pro/report). Replay that exact request from a logged-in free account, using your real session, without going through the gated button.
Run both checks per paid feature, not once. Entitlement is enforced (or not) endpoint by endpoint, so a protected export tells you nothing about the protected report next to it.
- Suspicious result: the paid feature now works, returns real data, or saves a result. That means the server trusted the client flag, and any user can do the same.
- Safer result: the UI may change, but the paid action fails with a 401, 403, or a payment-required style error from the server. The server made its own decision.
- Suspicious result: the endpoint returns 200 with the paid content for a free account. The gate was only the hidden button.
- Safer result: the endpoint returns 403 or a payment-required error for the free account, and 200 only for an account with an active subscription.
Move the decision to the server, and know its limits
The fix is to make the server the only authority on entitlement, and to derive that entitlement from a source the client cannot edit.
On every request to a paid feature, the server must look up the caller's subscription state itself rather than reading a flag the client sent. The trustworthy source is Stripe. After resolving the authenticated user to their Stripe customer, check the Subscription object for an active or trialing status and confirm the price or product matches the tier the feature requires. Many apps cache that state in their own database, which is fine as long as the cache is written by the server from verified Stripe data and never from a client request.
What this fix does not cover: it keeps entitlement honest, but it does not make the entitlement state correct. If your subscription status is wrong in the first place, a perfect server gate faithfully enforces the wrong answer. Status is correct only when your Stripe webhook updates it reliably, which depends on verifying the webhook signature so a forged event cannot grant a subscription. See the notes on Stripe webhook signature verification and the signals an exposed webhook endpoint gives off. The server gate and the webhook are two halves of the same control.
- Gate the endpoint, not the button. The export endpoint itself must return 403 to a caller without an active subscription, regardless of what UI rendered.
- Read entitlement server-side from Stripe (or a server-maintained mirror of it), keyed to the authenticated user. Never trust a plan field that arrived in the request body or a header.
- Keep the secret Stripe key (the sk_ key) server-only. The publishable pk_ key in the client is expected; a secret key in the bundle is a separate, serious problem.
Where VibeCodeGuard fits, and what it cannot prove
Be precise about the boundary here, because entitlement is exactly the kind of thing an external scan cannot fully judge. VibeCodeGuard's Launch Check looks at your public surface; it cannot confirm whether your server enforces subscription entitlement, because that is business-logic review of your authorization code.
At most, the scan can give a weak signal: if a paid-feature route returns 200 with real content to an unauthenticated request, that is reachable from the outside and the scan can flag it as exposed. But the bypass that matters most in this note involves an authenticated free user, and confirming whether that user is correctly refused requires manual code review of how your server resolves and checks entitlement. The scan does not replace that.
Next step: pick your single most valuable paid feature, log in as a free test account, and replay its endpoint directly. If it returns the paid content, you have a live bypass to fix before launch. Run a focused Launch Check against the public URL too, so any paid route that answers an unauthenticated request shows up in your scan rather than in someone else's.
A clean Launch Check does not prove your server gate is sound. It does not log in as a paying or non-paying user, replay your paid endpoints, or read your authorization logic. Treat entitlement enforcement as something you verify yourself with the two self-checks above, and treat the scan as a check on the public surface around it.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.