# Entitlements

> Stripe entitlements per tenant in the access token, in RLS, and fresh again after a plan change.

Source: https://bettersupabase.com/docs/blocks/entitlements

[Stripe Entitlements](https://docs.stripe.com/billing/entitlements) attach
features (`exports`, `sso`) to products; a customer's active entitlements
follow their subscriptions. The
[Stripe Sync Engine](https://github.com/supabase/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.

```bash
better-supabase sql add entitlements   # adds tenant as well
```

## Configuration [#configuration]

Each tenant needs its Stripe customer id somewhere. With the
[`billing` module](/docs/blocks/billing) installed alongside, the module reads
`better_supabase.billing_customers`, which checkout fills. Otherwise, with the
managed [`organizations` module](/docs/blocks/organizations), 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:

```ts title="better-supabase.config.ts"
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 [#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.

```ts title="better-supabase.config.ts"
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](/docs/blocks/usage) match by plan key.

### Feature values [#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):

```ts
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):

```sql
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](/docs/blocks/usage) 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 [#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:

```sql
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:

```sql
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 [#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](/docs/frameworks/next-cache-components#who-owns-what)
(`scope`, `id`, `roles`); an `entitlements` field on a membership means seats,
not plan features.

```json
{
  "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`:

```sql
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](#with-an-authorization-provider).

Declare the shape in `betterSupabase.claims()` to type it, with a `picklist` for the keys you
sell:

```ts title="src/lib/supabase/index.ts"
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>`](/docs/cli/doctor#bs405) 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:

```ts title="better-supabase.config.ts"
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 [#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:

```tsx
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]

With an [authorization provider](/docs/extending/authorization-providers) 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](/docs/cli/doctor#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 [#keeping-claims-fresh]

A token keeps the entitlements it was issued with until it is refreshed.
Two things close the gap:

1. **After checkout,** call `supabase.auth.refreshSession()` in the browser
   once the success page loads. The new token runs the hook again.
2. **On plan changes Stripe initiates** (renewal failures, cancellations at
   period end), handle `entitlements.active_entitlement_summary.updated` in
   the [webhook inbox](/docs/blocks/jobs#webhook-inbox) and drop the members'
   cached sessions:

```ts title="src/app/api/stripe/entitlements/route.ts"
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);
```

```ts title="src/app/api/cron/inbox/route.ts"
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.