Stripe manages each subscription’s status through its lifecycle, from creation to cancellation. The request a free account sends to your paid endpoint reaches your server, not Stripe. How to enforce plan limits on the backend comes down to one function your server runs before every paid operation, reading the plan from your own database and never from the browser.

What are entitlements in SaaS billing?

Entitlements in SaaS billing are what an account is allowed to do right now: which features are on, which numeric limits apply, and until when. Billing is what the account pays. The subscription links the two, and the entitlement is stored in your own database and checked by the server before any paid work.

Keeping the two apart is what lets a trial, an unpaid invoice or a comped account become a change to one row, with no change to the code that checks it. In the stack, the entitlement is one row per account in your own database: the verified webhook handler and admin operations write it, and the server reads it before paid work. The ways that row drifts from billing while Stripe’s dashboard stays green are the subject of your app gives away paid access for free.

Stripe has its own Entitlements feature, which maps your features to its products. Entitlement services such as Schematic and Stigg are the option to buy rather than build. For a small app, my view is that one row and one check are enough to start, and they sit inside the wider set of SaaS billing process best practices.

In the Production Hardening Sprint, this is deliverable 5.4: “Enforce paid-plan permissions and usage limits in backend actions.”

What goes wrong without it

The five failures below are my own grouping, not a vendor’s list. Each row is what the owner sees, why the request worked, and the check that stops it.

What happenedWhy it workedThe check that stops it
The paid button is hidden, but the endpoint answersNo plan check on the routeThe check inside every paid operation
The plan is a field the client can writeThe request supplied the planRead the plan from the entitlement row
The plan is read from a stale tokenThe token outlived the plan changeRead the row, not the token
The limit is counted in the browserThe browser holds the countCount on the server, in one step
The subscription ended and the flag stayedA one-way flag set at checkoutStore billing status and period end

Across the apps I audited in June and July 2026, 10 of the 21 third-party apps trusted the client: the server accepted whatever the browser asserted. In the same audits, 12 of the 14 AI apps had a confirmed denial-of-wallet path, where a stranger or free account can burn the owner’s paid AI or compute bill without limit.

The 21 are 11 public apps I audited across all 12 pillars plus 10 held-out apps I audited blind, and the 14 are the third-party AI apps I audited. They are a selected set, not a random sample, and not a rate for all AI-built apps.

My reading: a missing plan check on an AI feature is the expensive version of the same gap, and I suspect the pricing page and the upgrade button were in the prompt, while the check inside each paid route was not.

Free users accessing paid features

Free users accessing paid features means the paid route never looks at the plan. The interface hides the button, and the endpoint behind it answers any signed-in account that sends the same request. The fix is one plan check inside every paid operation.

The usual shape: the UI hides or locks the feature, and the API route, server action or edge function behind it never reads the plan, so a free account’s direct call to that route gets the paid result. Large products ship this class too; a public Jetpack issue is titled “Premium Content Block: Site followers can access paid content without paying for a subscription.”

One app I audited, an AI coding workspace, sold usage-based tiers, but the server never checked them: the plan tier was read only in browser components, and the usage-counting and “can use feature” database functions had zero callers. Nothing was metered and nothing was capped.

The lesson I take from it: a check function protects nothing until every paid route calls it. So the first job is a list of paid routes and a search of each one for the call. The read-path version, where paid data reaches a free account and only the page hides it, is the paid-access article’s section on a paywall that lives only in the browser.

A user changed their plan in browser DevTools

A user who changed their plan in browser DevTools edited a value the app should never have trusted. Anything the browser holds can be edited by its owner. The server must read the plan from one place, its own database, and ignore any plan the request claims.

When a user changed plan in browser DevTools and the app believed it, the app had decided what to unlock from a value the browser holds: a plan field in local storage, a flag in the page’s state, or a field in the user’s own editable profile row. Supabase’s row-level security guide says raw_user_meta_data “can be updated by the authenticated user” and “is not a good place to store authorization data”, while raw_app_meta_data “cannot be updated by the user”.

Editing what your own browser holds is ordinary browser behavior, not a hack. So the fix is never to hide or scramble the value: the server stops accepting a plan from the request at all. Try the edit only on your own app, with your own test account.

A user accessed a paid feature without paying

A user who accessed a paid feature without paying may have paid once: the flag set at checkout stayed on after the trial ended, the card failed or the subscription was canceled. Access has to follow two live values, billing status and period end, not a one-time flag.

This is the billing-state version. The account was paid once and is not now, and nothing cleared the flag set at checkout. When the account belongs to a canceled subscription, the step-by-step diagnosis is in a canceled Stripe subscription that still has access. The rule itself, access ends when payment ends, is one of the conditions to meet before you take money. What changes here is only where the check reads from: the row’s billing status and period end, never a one-way flag.

How to enforce plan limits on the backend: one check in front of every paid action

Plan limits are enforced on the backend by one function, called before every paid operation, that reads the account’s entitlement row and answers yes or no. The request never supplies the plan. A refusal returns a clear error the interface turns into an upgrade prompt, and no paid work starts before the answer.

The rule: every paid operation calls one server function, can(account, action), before it does any work. The function reads the entitlement row, never the request body, a header or a token claim about the plan. My working rule for the refusal is an error with a reason code the UI can read, so the front end shows an upgrade prompt instead of a generic failure.

The entitlement row: plan, features, limits, status, period end

The table is my design for a small app; the column names are examples, not a standard.

ColumnExampleWho writes it
account_idThe account the row belongs toServer code, once, when the account is created
plan_keyfree or proThe verified webhook handler
features{"export": true, "priority_support": false}, or a plans table joined on plan_keyThe verified webhook handler, through the plan
limitsprojects_max, seats_max, generations_per_monthThe verified webhook handler, through the plan
billing_statusThe provider’s subscription status, copied as it isThe verified webhook handler
period_endWhen the current paid period endsThe verified webhook handler
override_until, override_reasonAn end date and a reason for a comped accountAdmin operations only

No column is ever written by a browser. The handler that writes the row checks the provider’s signature first, the subject of webhooks security, and the override comes only from a guarded admin operation, one item on the admin panel security checklist.

On Supabase, my rule is a policy that lets the owner read their own row and gives signed-in users no write path at all, so only trusted server code writes it. That server code usually holds a secret key, which works through the service_role Postgres role with the bypassrls attribute, and Supabase’s guide says never to use a secret key in the browser. The same guide adds that a secret key bypasses RLS only when the request carries no user access token, so the code that writes the row must not pass a signed-in user’s token along.

Keeping the row equal to the provider’s truth is a separate job: keep Stripe subscriptions in sync with database records. Which events move the status belongs to the subscription lifecycle in Stripe, and how a failed renewal and its retry window should move access is how to handle failed subscription payments. Which subscription status should grant, keep or revoke access, status by status, is the table in the paid-access article mentioned earlier; the check only applies it, by reading billing_status and period_end from this row.

Where the check goes on Next.js, Supabase and a plain API

Before reading any code, go to the Table Editor page in the Supabase Dashboard, open the entitlement table, and confirm that a free test account’s row says free. If it says pro, or no such table exists, the check below has nothing true to read.

  1. Plain API (Express or similar): put a requireEntitlement('export') middleware on each paid route, after authentication, so the handler never runs for an account the row refuses.
  2. Next.js: call the check inside each Server Action and Route Handler that does paid work. The Next.js authentication guide says to treat both “with the same security considerations as public-facing API endpoints”.
  3. Supabase with a builder-made front end (Lovable and the like): move paid work behind an Edge Function or a database function called over RPC that reads the caller’s entitlement row before acting. Give paid tables a policy that checks the entitlement row too, because in my reading a query the browser sends straight to a table has no other gate. Supabase’s RLS guide documents this kind of policy, one whose condition looks up a row in another table with exists (select 1 from ...).

The function and one guarded route, in TypeScript with an Express-style route; nothing in it is specific to a billing provider:

// Server only. The plan comes from your database, never from the request.
async function can(accountId: string, action: string): Promise<boolean> {
  const row = await getEntitlementRow(accountId);
  if (!row) return false;
  const now = new Date();
  const paidUp = GRANTING_STATUSES.includes(row.billingStatus) && row.periodEnd > now;
  const comped = row.overrideUntil !== null && row.overrideUntil > now;
  return (paidUp || comped) && row.features[action] === true;
}

app.post('/api/export', requireAuth, async (req, res) => {
  if (!(await can(req.user.accountId, 'export'))) return res.status(403).json({ error: 'upgrade_required' });
  // paid work starts only after the check
});

GRANTING_STATUSES is the list your status table grants access for. The requireEntitlement middleware in the first item is this same function wrapped for the router.

Counted limits: check, then do the work, in one step

A counted limit, such as a project cap or a monthly generation quota, is checked and incremented in one atomic step on the server, before the expensive work. Two requests arriving together at the last unit must not both pass, and the browser never reports its own count.

Features are yes or no; limits are counted: projects, seats, generations a month. The count lives in the database, the server reads it, and the check and the increment happen in one conditional statement, UPDATE usage SET used = used + 1 WHERE account_id = :id AND used < cap RETURNING used, or one transaction that locks the row with SELECT ... FOR UPDATE before it writes. A row back means the unit is taken. No row back means the limit is reached, and the request is refused before any work starts. If the work then fails, my rule is to give the unit back in the error path. After a downgrade, an account can already hold more than its new cap: the same conditional update refuses its next unit, and my rule is a scheduled job that lists the accounts over their cap, so what happens to the extra ones is a decision, not an accident.

Either form is safe under concurrency because of how PostgreSQL behaves at Read Committed, its default isolation level. When two requests update the same row, the second waits for the first to commit or roll back; if the first committed, “the search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition”, as PostgreSQL’s transaction isolation chapter puts it. At the last unit, the second request sees used already equal to cap and gets no row back. At Repeatable Read, if the first commits and actually changed the row, the second update fails instead, with “could not serialize access due to concurrent update”, and my rule there is to refuse or retry.

Metering for usage-based pricing, including the browser reporting its own usage and a limit checked after the work, has its own article: usage-based billing mistakes. Request-per-minute rate limits are an abuse control, not a plan limit.

How to verify it: test a paid endpoint with an expired plan, a free account and a paid one

Backend plan enforcement is verified with three accounts: never paid, paid then expired, and paid and current. Each paid operation is replayed as all three. Only the current account may succeed, the other two get the refusal, and nothing is written or spent. The dated matrix is the evidence.

The goal is to test the paid endpoint with an expired plan account, a never-paid account and a current one, and keep what each request returned. Run it in a Stripe sandbox, on your own app only.

  1. 01 List every paid operation and every counted limit. Evidence: the list, which becomes the first column of the matrix below.
  2. 02 In a Stripe sandbox, create three accounts: one that never paid, one whose paid plan has lapsed, and one with a current paid plan. For the expired one, create a test clock, create its customer on that clock with a test card as its default payment method, link that customer to the app account the way your checkout does, subscribe it, set the subscription to cancel at period end, and advance the clock past the period end. Keep the app's webhook endpoint receiving the sandbox's events, so the row moves as it would in production. Evidence: the three account ids and the two Stripe customer ids.
  3. 03 Sign in as the paid account, run each paid operation, and save each request with its method, path, headers and body. Evidence: the saved requests.
  4. 04 Replay each saved request with the free account's credentials, then the expired account's, by swapping in their session cookie or Authorization header. A replay that keeps the paid account's credentials cannot fail. Evidence: each status code and response body.
  5. 05 Expect the paid account to succeed and the other two to be refused, with nothing written or spent. Evidence: the entitlement row and the provider's usage, before and after.
  6. 06 For one counted limit, fill it to one below the cap, send two requests at the same moment, and confirm exactly one passes. Evidence: the counter's value, equal to the cap.

Step 2 rests on Stripe test clocks, which Stripe’s simulations use to control time: the customer is created on the clock, the subscription follows the clock through the customer, and a monthly subscription can move forward at most two months per advance. When a subscription set to cancel at period end reaches the end of its billing period, Stripe sends customer.subscription.deleted and the subscription is canceled.

For step 5, my working rule is a 403 with a reason code, or whatever refusal status the app has standardized on. One exception: a read that goes straight to a Supabase table under a policy returns zero rows with no error when the policy filters the row out, so there the pass is an empty result. Then one more check on the free account: edit the plan value the browser holds, reload, and call a paid operation. Evidence: the same refusal after the edit.

Paid operationFree accountExpired accountPaid account
Export to CSVRefused, no fileRefused, no fileSucceeds
Invite a seatRefused, no invite sentRefused, no invite sentSucceeds
Create one more project than the capRefusedRefusedRefused at the cap, count unchanged
Run a generationRefused, nothing spentRefused, nothing spentSucceeds, counter up by one

Keep the matrix as an automated test that runs on every pull request, with its date and the saved responses (my working rule). The quick one-request version is the direct-call check in the paid-access article.

In the sprint, deliverable 5.4 is verified this way: “Attempt paid operations with free, expired, and valid paid accounts.”

Where the sprint does this

The 5.4 result and its evidence go into the production readiness report, deliverable 13.1, where we account for all 123 IDs, keep failures visible until resolved and explain genuine non-applicable items. Building new product features or modules is outside the sprint. The rest of the billing work is listed in the payments area of the published scope.

Common questions about entitlements

What are entitlements in billing?

Inside a billing product, an entitlement is a customer’s access to a feature, tied to a product the customer buys. Stripe’s Entitlements feature lets you “map the features of your internal service to Stripe products”, then notifies you when to provision or de-provision access according to the customer’s subscription status, through the entitlements.active_entitlement_summary.updated event. Your server still runs the check before paid work, and Stripe’s Entitlements documentation recommends persisting entitlements internally for faster resolution.

What are examples of entitlements?

Entitlements are features or limits: export to CSV on or off, a seat count, a project cap, a monthly generation quota, a priority-support flag. Export and priority support are yes-or-no features; seats, projects and generations are counted limits, which need the one-step check.

What are entitlement rules?

Entitlement rules are the mapping from plan and billing status to features and limits: which plan unlocks export, how many seats each plan gets, what a canceled account keeps. My working rule is to keep them in one table or one function, so pricing can change without touching the routes that call the check.

What do entitlements mean in software?

In SaaS software, entitlements mean what an account may use right now: its features, its limits and the date they run to. On Apple platforms the word names a different thing, which this article does not cover.