Skip to content
← Back to the blog
securityproduct

How to limit features by plan in a SaaS (server-side checks)

Hiding a button is not a paywall. How to gate features and usage limits by plan on the server, with Stripe entitlements or your own table and database rules.

By Devonauts Team5 min read
Leer en español →
A smartphone wrapped in a metal chain and locked with a combination padlock on a wooden table

To limit features by plan in a SaaS, every request that uses a paid feature must be checked on the server against the user's current plan, which your backend reads from data your Stripe webhook keeps up to date. Hiding a button in the interface is only a convenience: anyone can call your API or database directly and skip it. The reliable setup is one place that says what each plan includes, one function that answers "may this user do this?", and that function called in your API routes and database rules.

This guide covers why client-side paywalls leak, how to model plans and limits, where to enforce them, how to handle downgrades and how to test that the paywall actually holds.

Why isn't hiding the feature in the frontend enough?

In apps built with Lovable, Bolt or similar tools, a common pattern is if (user.plan === 'pro') showExportButton(). The button disappears for free users, but the action behind it is still open:

  • The browser already has your Supabase URL and public key, so a user can call the table or Edge Function directly.
  • The plan value often lives in React state or localStorage, which the user can edit in the developer tools.
  • Network requests can be replayed from the browser with a different payload.

The frontend check is still useful, because it gives a good experience: show the upgrade prompt instead of an error. It just can't be the thing that enforces the rule.

Where should the plan information come from?

From your database, written by your webhook, never from the browser. You have two reasonable models.

Option 1: Stripe Entitlements

Stripe lets you create features (each with a unique lookup_key, for example pdf_export) and attach them to products. When a customer subscribes, Stripe creates active entitlements for the features of that product and sends entitlements.active_entitlement_summary.updated whenever they change, including upgrades, downgrades and cancellations. Your webhook stores the list of active feature keys per customer.

Two details from Stripe's docs: the summary in the webhook contains at most 10 entitlements, so for more you fetch the full list through the API; and when you change which features a product includes, existing subscriptions pick up the change at the start of their next billing period.

Entitlements answer "does this customer have feature X?". Numeric limits, such as 3 projects or 10,000 AI tokens a month, are still yours to define.

Option 2: Your own plan table

Store the plan on your subscription row (from the price's lookup key) and keep a small table of what each plan allows:

planmax_projectspdf_exportapi_access
free1falsefalse
pro10truefalse
team50truetrue

This is simpler to start with and keeps limits next to features. Whichever you choose, keep the definition in one place. Plan logic spread across ten components is how one screen ends up enforcing an old limit. If your subscription data isn't reliable yet, fix that first with our guide to Stripe subscriptions and webhooks.

Where do you enforce the limits?

Enforce at every entry point that can perform the action.

  1. API routes and server functions. Add one helper, for example await requireFeature(user, 'pdf_export'), at the start of each paid route. It reads the stored plan and returns a 403 with a clear message if the user doesn't qualify.
  2. Database rules. If the browser talks to the database directly, as with Supabase, put the rule in Row Level Security so it holds even when your API is bypassed.
  3. Background jobs and webhooks. Scheduled tasks that generate reports or call AI models should check too, or a downgraded account keeps getting paid work.

A Supabase policy that limits how many projects a user can create might look like this:

create policy "project limit by plan" on projects
for insert to authenticated
with check (
  owner_id = auth.uid()
  and (select count(*) from projects p where p.owner_id = auth.uid())
      < coalesce(
          (select pl.max_projects
             from subscriptions s
             join plan_limits pl on pl.plan = s.plan
            where s.user_id = auth.uid()
              and s.status in ('active', 'trialing')),
          1)  -- users without a paid plan get the free limit
);

Count-based checks like this can be beaten by two requests arriving at the same moment. For limits where that matters, such as credits or AI usage, keep a counter and update it in the same transaction as the action. If you need examples of policies for other patterns, see our Supabase RLS policy examples.

What about usage limits on AI features?

Per-request features are yes or no; AI features are usually "how much". Track usage in your own table as it happens, check the remaining allowance before calling the model, and decide what happens at the limit: block, slow down, or bill overage. Billing for the overage is covered in usage-based billing with Stripe, and protecting your costs from a single heavy user in rate limiting AI features.

What happens when a customer downgrades?

Decide this before the first downgrade, not during it.

  • Keep the data, limit the action. A user on the free plan with 7 projects keeps all 7 but can't create an eighth. Deleting data on downgrade generates angry emails.
  • Make extra items read-only if keeping them fully usable would make the paid plan pointless.
  • Time it with billing. If downgrades take effect at the end of the period, which the customer portal supports, your webhook will receive the change then. Don't remove features the moment they click.

How do you test that the paywall holds?

  1. Sign in as a free user and copy a request your app makes for a paid feature from the browser's Network tab.
  2. Replay it, for example with curl. It should fail with a 403 or a database error, not succeed.
  3. In a sandbox, upgrade the user and confirm the same request now works, with no code change or redeploy.
  4. Downgrade and cancel, and confirm access changes exactly when your policy says it should.

Quick answers

Is a feature flag tool the same as feature gating? Not quite. Flag tools are great for rollouts, but the decision for paid features still has to be enforced on the server from billing data.

Should I check the plan by calling Stripe on every request? No. It's slow and adds a dependency on every page load. Read your own stored copy, kept current by webhooks.

What status should count as paid? Usually active and trialing. Decide deliberately about past_due; our failed payments guide covers the trade-offs.

Do I still need the frontend check? Yes, for the user experience. Just never only the frontend check.

Want someone to try to get around your paywall?

The quickest way to know whether your paid features are protected is to have someone attempt to bypass them. Our Stripe payments service and Supabase security review both cover this. You can also start with the free app health check, read Stripe's entitlements docs, or contact us.

Still stuck with your app?

Run the free health check on your app's URL, or get the $299 App Rescue Audit: a senior engineer reviews the code and sends you a written report in 2 business days.