Entitlements
Stripe entitlements per tenant in the access token, in RLS, and fresh again after a plan change.
Stripe Entitlements attach
features (exports, sso) to products; a customer's active entitlements
follow their subscriptions. The
Stripe Sync Engine
mirrors them into stripe.active_entitlements. The entitlements SQL module
reads that table per tenant, so the UI can hide what a plan doesn't include
and RLS can refuse it.
better-supabase sql add entitlements # adds tenant as wellConfiguration
Each tenant needs its Stripe customer id somewhere. With the
billing module installed alongside, the module reads
better_supabase.billing_customers, which checkout fills. Otherwise, with the
managed organizations module, it adds
better_supabase.organizations.stripe_customer_id (unique) and reads it.
Without either, point the module at your own column; sql add stops without one:
export default defineConfig({
entitlements: {
customer: "billing_customers.stripe_customer_id",
key: "organization_id", // the tenant id column, defaults to id
},
});sql add writes tenant_stripe_customer(tenant) for that column, so the
file fails to apply if the column doesn't exist, rather than failing on every
request later. The module reads the tables of Stripe Sync Engine 0.48.5
(SPEC_PINS.stripeSyncEngine). Before the engine is installed, every tenant
has no entitlements.
Other sources
Apps that keep their own billing mirror don't need the Sync Engine.
entitlements.source picks where the features come from; the checks,
the claim and hasEntitlement stay the same.
A plan catalog in your tables: the tenant's subscription names a plan, and a
features table lists what each plan includes. With status, only
subscriptions in activeStatuses (active and trialing by default) count;
with included, only rows where it is true.
export default defineConfig({
entitlements: {
source: {
plans: {
subscriptions: {
table: "public.subscriptions",
tenant: "organization_id",
plan: "plan_key",
status: "status",
},
features: {
table: "public.plan_features",
plan: "plan_key",
feature: "feature_key",
included: "included",
},
},
},
},
});With a plan catalog, the module also writes
better_supabase.tenant_plans(tenant), the tenant's active plan keys, which
usage quotas match by plan key.
Feature values
A feature can carry a value, such as how many days of file history a plan
keeps (30, 90 or 365) or a support level, so the tier needs no parsing from
the feature key. Name the column in features.value (a number, text or
jsonb column):
features: {
table: "public.plan_features",
plan: "plan_key",
feature: "feature_key",
value: "value",
},better_supabase.tenant_entitlement_value(tenant, key) returns it as
jsonb: the largest number when several active plans set the feature,
otherwise the first plan's value, true for a feature whose value is null,
and null when the tenant lacks the feature. entitlement_value(tenant, key)
returns the same to a member of the tenant (null to anyone else):
select (better_supabase.tenant_entitlement_value(organization_id, 'file_history_days') #>> '{}')::integer;The Sync Engine source has no values, since Stripe entitlements are on or
off: its tenant_entitlement_value returns true or null. For limits that
are counted, such as API calls per month, use
usage quotas instead; a value is for a setting the
plan fixes.
source: "custom" writes everything except
better_supabase.tenant_entitlements(tenant), which you write yourself and
which returns the tenant's feature keys as text[], such as plan features
plus per-tenant overrides, and
better_supabase.tenant_entitlement_value(tenant, key), which returns a
feature's value as jsonb for entitlement_value. Neither source needs customer, and
entitlementMembers (the Stripe event helper below) applies to the Sync
Engine only.
SQL
| Function | Grants | Returns |
|---|---|---|
tenant_entitlements(tenant) | service_role, supabase_auth_admin | Sorted lookup keys, {} without a customer |
has_entitlement(tenant, key) | authenticated | true when the caller is a member of tenant and it has key |
tenant_entitlement_value(tenant, key) | service_role | The feature's value as jsonb, true without one, null without the feature |
entitlement_value(tenant, key) | authenticated | The same for a member of tenant, null for anyone else |
tenant_ids_with_entitlement(key) | authenticated | The caller's tenants that have key, for one set check per query |
feature_claims(user_id) | service_role, supabase_auth_admin | The features claim for the user: lookup keys per tenant |
entitlement_members(customer) | service_role | Members of the tenants billed to customer |
RLS reads the live table, so a downgrade takes effect on the next query:
create policy exports_need_plan on public.exports as restrictive
for insert to authenticated
with check ((select better_supabase.has_entitlement(organization_id, 'exports')));On a read policy over many rows, compare against the set instead, so Postgres evaluates the entitlements once per query rather than once per row:
create policy exports_read on public.exports for select to authenticated
using (organization_id in (select better_supabase.tenant_ids_with_entitlement('exports')));In the access token
feature_claims builds the features claim: the plan's lookup keys per
tenant the user is a member of. Plan features stay out of memberships, whose
entries follow the
claims contract
(scope, id, roles); an entitlements field on a membership means seats,
not plan features.
{
"memberships": [{ "scope": "tenant", "id": "4f1c…", "roles": ["admin"] }],
"features": { "4f1c…": ["exports", "sso"] }
}Call it from your custom access token hook. The tenant module's
membership_claims fills memberships:
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
set search_path = ''
as $$
declare
uid uuid := (event ->> 'user_id')::uuid;
begin
event := jsonb_set(event, '{claims,memberships}', better_supabase.membership_claims(uid));
return jsonb_set(event, '{claims,features}', better_supabase.feature_claims(uid));
end
$$;
grant execute on function public.custom_access_token_hook(jsonb) to supabase_auth_admin;The claim name comes from claims.features in the config (features by
default). With an authorization provider's hook, see
below.
Declare the shape in betterSupabase.claims() to type it, with a picklist for the keys you
sell:
const Claims = v.looseObject({
features: v.optional(
v.record(v.string(), v.array(v.picklist(["exports", "sso", "audit"]))),
{},
),
});
export const betterSupabase = defineSupabase(schema).claims(Claims);Every key travels in every request, so keep the list to lookup keys.
doctor --as <user id> warns when the hook's
claims pass 2 KB. A provider's hook budget (tokenHook.budget) covers only the
claims it lists, so features is usually outside it.
entitlements.claim controls the claim's size and shape:
export default defineConfig({
entitlements: {
claim: { maxTenants: 20, keys: { exports: "e", sso: "s", audit: "a" } },
},
});| Setting | What the claim holds |
|---|---|
| unset | every tenant with entitlements, with their keys |
false | {}; check entitlements in SQL with has_entitlement only |
{ maxTenants } | at most that many tenants, the lowest ids first |
{ keys } | the short code for each key, and the key itself for keys without one |
hasEntitlement reads the short codes when you pass the same map:
hasEntitlement(session, tenantId, "exports", { claim: "features", keys }).
A tenant left out by maxTenants reads as not granted, so pair it with
has_entitlement in RLS and server checks. Run better-supabase sql sync
after changing it.
In components
hasEntitlement(session, tenantId, key) reads the features claim, from
better-supabase/blocks/entitlements. Pass
the claim name as a fourth argument when claims.features renames it. With a
typed claims schema, key only accepts its lookup keys:
import { hasEntitlement } from "better-supabase/blocks/entitlements";
const session = await bs.session();
if (!hasEntitlement(session, organizationId, "exports"))
return <UpgradePrompt />;This is UX: the policy above still decides.
With an authorization provider
With an authorization provider in
the config, the module reads memberships through the provider's memberIds
and memberIdsFor functions instead of the tenant module, and
sql add entitlements no longer adds tenant:
| Function | Membership check |
|---|---|
has_entitlement(tenant, key) | tenant in (select <memberIds>), as the signed-in user |
feature_claims(user_id) | <memberIdsFor> for user_id, from the provider's hook as supabase_auth_admin |
entitlement_members(customer) | the provider's memberships tables for the tenant scope |
The scope is the provider's tenantScope, and the tenant argument of
has_entitlement, tenant_entitlements and tenant_stripe_customer takes
that scope's idType. When the provider lacks memberIds or
memberIdsFor, or the scope has no idType, sql add entitlements stops and
doctor reports BS408. BS408 also reports a tenant
scope without a memberships entry, which entitlement_members needs.
entitlements.memberships: "tenant" keeps the tenant module's
better_supabase.memberships even with a provider.
features is not a claim a provider usually owns, so sql add entitlements
works without --force. For hasEntitlement(session, ...), the provider's
hook must write the features claim from better_supabase.feature_claims.
List it in tokenHook.registeredClaims and doctor's BS407 accepts the second
writer. Grant supabase_auth_admin execute on the memberIdsFor function,
which feature_claims calls; doctor (BS408) reports a database where it
can't.
The module's feature_claims reads Stripe's stripe.active_entitlements, so
an app whose plan features live in its own table keeps its own
feature_claims and names that one in the hook instead.
Keeping claims fresh
A token keeps the entitlements it was issued with until it is refreshed. Two things close the gap:
- After checkout, call
supabase.auth.refreshSession()in the browser once the success page loads. The new token runs the hook again. - On plan changes Stripe initiates (renewal failures, cancellations at
period end), handle
entitlements.active_entitlement_summary.updatedin the webhook inbox and drop the members' cached sessions:
import { dbError, err, ok } from "better-supabase";
import {
ENTITLEMENTS_UPDATED,
entitlementMembers,
} from "better-supabase/blocks/entitlements";
import { createWebhookInbox } from "better-supabase/blocks/jobs";
const inbox = createWebhookInbox(postgres.admin, {
source: "stripe-entitlements",
verify: async (request, body) => {
try {
const event = await stripe.webhooks.constructEventAsync(
body,
request.headers.get("stripe-signature") ?? "",
process.env.STRIPE_ENTITLEMENTS_SECRET!,
);
return ok({ id: event.id, payload: event });
} catch (cause) {
return err(dbError("unauthorized", String(cause)));
}
},
});
export const POST = (request: Request) => inbox.receive(request);export const GET = async () => {
const result = await inbox.process(async (message) => {
if (message.type !== ENTITLEMENTS_UPDATED) return;
const members = await entitlementMembers(
postgres.admin,
message.payload,
).orThrow();
for (const userId of members) bs.invalidateSession(userId);
});
return Response.json(result);
};invalidateSession drops bs.cached() entries, so the next render asks
for the session again. The token itself only changes on refresh, which the
proxy does when it expires. Send the event to its own endpoint: the Stripe
Sync Engine keeps its webhook for syncing the tables.
Last updated on