# Supabase token hook

Source: https://permdock.com/docs/adapters/supabase-hook

permdock supabase hook generate compiles the app's membership sources into one custom_access_token_hook, with the active scope first, a size budget, an authorization version for sensitive permissions, and protection for memberships the identity provider owns.

A Supabase app usually reads memberships in two places: the server asks its tables on every request, and the access token carries a copy so RLS and the browser do not have to. The two drift apart as soon as they are written by hand. `permdock supabase hook generate` removes the second copy of the logic: the app declares its membership sources once, passes them to `createPermDock` as `memberships`, and the generator compiles the same sources into the Custom Access Token Hook.

## Sources [#sources]

`permdock/supabase` exports two SQL membership sources. Each one runs a single parameterised `select` at runtime and hands the same `select` to the generator, so the claim the hook writes and the memberships the server reads come from identical SQL.

```ts
// src/memberships.ts
import { fromJunction, fromTable, type SqlQuery } from "permdock/supabase";

const suspension = {
  users: { table: "profiles", id: "id", disabledAt: "disabled_at" },
  scopes: {
    organization: {
      table: "organizations",
      id: "id",
      disabledAt: "disabled_at",
    },
  },
};

export function sources(query?: SqlQuery) {
  const shared = query === undefined ? { suspension } : { query, suspension };
  return [
    // one row per user, scope, instance and role
    fromTable({
      table: "memberships",
      columns: {
        via: "via",
        expiresAt: "expires_at",
        managedBy: "managed_by",
        seats: "seats",
      },
      ...shared,
    }),
    // portal contacts: every row is a customer membership with the fixed role `contact`
    fromJunction({
      table: "customer_contacts",
      scope: "customer",
      within: { organization: "organization_id" },
      roles: ["contact"],
      via: "contact",
      ...shared,
    }),
  ];
}
```

* `fromTable({ table, columns?, query?, suspension? })` reads a table that holds every scope. `columns.user`, `scope`, `id` and `role` default to `user_id`, `scope`, `scope_id` and `role`. `columns.role` may be a reference to a roles table, and `columns.user` a reference to a table that holds the user id (below). `within` names a `jsonb` column of ancestor ids; `via`, `expiresAt`, `disabledAt` (a nullable timestamp that suspends the row: [suspended memberships](/docs/cli/rls#suspended-memberships)), `managedBy` (a text column where `idp` marks an IdP-owned row), `seats` (a `text[]` column) and `x` (a `jsonb` column of app data, read into `Membership.x` and never written into claims) are optional. Rows for the same instance, kind, expiry, owner, seats and `x` merge into one membership with every role.
* `fromJunction({ table, scope, roles, user?, id?, within?, via?, expiresAt?, disabledAt?, managedBy?, seats?, x?, query?, suspension? })` reads a table of one scope. `roles` is a role column, a reference to a roles table (below), or fixed roles (`['contact']`), so a contact table needs no role column. `user` is the user id column (default `user_id`) or a reference to a table that holds the user id (below). `id` defaults to `<scope>_id`, `within` maps each ancestor scope to its column, `via` is the kind every row has, and `managedBy` is `'idp'` (every row) or `{ column }`.
* `query(text, values)` runs one statement: `pg`'s `client.query` and `postgres`'s `sql.unsafe` both fit. Without it a source only describes SQL, which is all `permdock.config.ts` needs.
* `suspension` has the shape of [`rls.suspension`](/docs/cli/rls): rows of a suspended user, instance or ancestor instance are left out, and a missing status row counts as suspended. A scope with `keep` keeps the rows of its suspended instances instead, each with a `keep` column of the permission keys it still grants ([permissions a suspended scope keeps](/docs/cli/rls#permissions-a-suspended-scope-keeps)); a source without `keep` adds the column only next to one that has it. A row whose `disabledAt` column is set is a suspended membership: it is left out, or with `suspension.memberships.keep` kept with the keys it still grants ([suspended memberships](/docs/cli/rls#suspended-memberships)).
* Each source also implements `list({ scope, id })`, which returns every member of one instance for member lists, share dialogs and access reviews.

A membership table that stores a role id reads the role key through the roles table, with the same `{ through, on, column }` shape as [global roles](#generate):

```ts
// organization_users (user_id, organization_id, role_id references roles (id))
// roles (id, scope, key, organization_id): built-in rows have no organization, custom rows have one
fromJunction({
  table: "organization_users",
  scope: "organization",
  roles: { through: "roles", on: { role_id: "id" }, column: "key" },
  via: "staff",
  ...shared,
});
```

`on` maps exactly one column of the membership table to the roles table column it references, and an unqualified `through` is in the membership table's schema. The hook, the in-process `membershipsFor` and `list`, and the `database` mode helpers all run the same `select` with the join, so a membership's `roles` hold keys. A tenant's custom role is a roles row with a key unique within the tenant; the membership carries that key and resolves as a [custom role](/docs/concepts/custom-roles) of its tenant. A tenant row keyed like a declared role (`owner`) holds that declared role, so keep the keys apart with a check constraint or the code that writes roles.

A membership row that holds roles in more than one column (a plan tier and a role id) lists them as role sources: `fromTable` `columns.role` takes an array, and `fromJunction` takes `roles: { sources }`, because an array of strings there is fixed roles.

```ts
// organization_users (user_id, organization_id, tier text, role_id references roles (id))
fromJunction({
  table: "organization_users",
  scope: "organization",
  roles: {
    sources: [
      "tier",
      { through: "roles", on: { role_id: "id" }, column: "key" },
    ],
  },
});
```

The row holds every non-null key of its sources: a user with `tier = 'member'` and a `role_id` keyed `admin` gets `roles: ["admin", "member"]`, and a row whose sources are all null holds no membership. The hook, `membershipsFor`, `list` and the `database` mode helpers expand the row with one `cross join lateral`, and a key rename on any referenced roles table bumps every holder's authorization version. Each source column is a deciding column, and the manifest lists the sources as an array under `role`.

A membership table whose rows name a profile instead of a login reads the user id through the profile table, with the same shape. CentraKit's portal contacts are `customer_contacts (customer_id, organization_id, contact_profile_id)`, and the login is `contact_profiles.user_id`:

```ts
// contact_profiles (id, user_id references auth.users (id) on delete set null)
// customer_contacts (customer_id, organization_id, contact_profile_id references contact_profiles (id))
fromJunction({
  table: "customer_contacts",
  scope: "customer",
  id: "customer_id",
  within: { organization: "organization_id" },
  user: {
    through: "contact_profiles",
    on: { contact_profile_id: "id" },
    column: "user_id",
  },
  roles: ["customer"],
  via: "contact",
  ...shared,
});
```

The `select` joins the profile table and filters on its user column, so a row whose profile is missing or has no `user_id` holds no membership, and `suspension.users` checks the joined user id. A source may read both its user and its role through other tables. The hook declares the lookup variable with the profile column's type, so its index applies; `rls generate` indexes the profile's user column and the reference.

A source's deciding columns (the user, scope, id, `within`, role, `via` and expiry columns, the `id` and key columns of a roles table its role references, and the `id` and user columns of a table its user references) must not be client-writable, or a user can give themselves a membership: a portal contact who may edit their own `customer_contacts` row could set `user_id` on another row, or `customer_id` on their own. Revoke `insert` and `update` on the table from `anon` and `authenticated` and grant `update` back only on the columns clients edit:

```sql
revoke insert, update on public.customer_contacts from anon, authenticated;
grant update (name, phone) on public.customer_contacts to authenticated;
```

A column-level `revoke update (user_id)` alone is not enough: Postgres keeps a table-level `update` grant (Supabase's default privileges give one on every `public` table), and `has_column_privilege` still answers true. Doctor PD028 warns on each deciding column the migrations leave writable.

On the server, pass the sources as an array. `createPermDock` composes them with `composeMemberships`: memberships are merged and de-duplicated, entries that differ in `via`, expiry, owner or seats stay separate, and a source that throws makes the whole lookup fail closed.

```ts
import { claimsFirst, createPermDock } from "permdock";
import { authzVersion, subjectFromSupabase } from "permdock/supabase";

const query: SqlQuery = async (text, values) =>
  (await pool.query(text, [...values])).rows;
const memberships = claimsFirst(sources(query), {
  version: authzVersion({ query }),
});

const { data } = await supabase.auth.getClaims();
const permdock = await createPermDock(
  policy,
  subjectFromSupabase(data?.claims ?? null),
  { memberships },
);
```

`claimsFirst(sources, { version?, onStale? })` keeps the memberships the verified token carries and reads the sources only when the token says it dropped some (`memberships_truncated`), or, with `onStale: 'reread'`, when the token's `authz_ver` is behind the version table (one version read, then one memberships read only when the token is behind). Without `claimsFirst`, a `memberships` source is always read and the token's copy is ignored. `getPermDock` in [`permdock/next`](/docs/adapters/next) takes the same `memberships` option.

### A stored user over PostgREST [#a-stored-user-over-postgrest]

The sources above run SQL through `SqlQuery`. A backend that reaches Postgres only through supabase-js reads the same answers from `subject_for(p_user uuid) returns jsonb`, which the hook file defines next to the hook: the global roles, the memberships of every source with their expiry and suspension filters, the authorization version, and in `database` mode with `rls.customRoles` the custom roles the user holds with their grants and includes. A suspended user comes back as `{ id, active: false }`, an unknown one as `null`. `execute` is revoked from `public`, `anon` and `authenticated`, because the caller names the user; grant it to the role your backend client uses. The hook file also defines `members_of(p_scope text, p_id text) returns jsonb`: every live membership of one scope instance as `[{ principal: { id }, membership }]`, from the same sources with the same expiry and suspension filters, under the same grants. It also defines `authz_version_for(p_user uuid) returns bigint`: the authorization version `subject_for` reports, `null` for an unknown or suspended user, read without the roles and memberships.

The Data API serves only exposed schemas, and `permdock` is not one. Set `supabase.hook.api` and `hook generate` writes a wrapper for each of the three in an exposed schema:

```ts title="permdock.config.ts"
supabase: {
  hook: {
    memberships: [/* ... */],
    api: { schema: "public", prefix: "permdock_" }, // both are the defaults
  },
},
```

That writes `public.permdock_subject_for(p_user uuid)`, `public.permdock_members_of(p_scope text, p_id text)` and `public.permdock_authz_version_for(p_user uuid)`. Each is a `security definer` function with an empty `search_path` that calls the function in the hook schema, revoked from `public`, `anon` and `authenticated` and granted to `service_role`: the only grant the hook makes to it, and only with `api`. Without `api`, no wrapper is written and nothing is granted to `service_role`. `generate` refuses an `api` that would replace the function it wraps (the hook schema with an empty prefix).

`postgrestSources(client, { api?, schema?, fn?, membersFn?, versionFn?, policy? })` in `permdock/supabase` calls `subject_for` once per user and `members_of` once per instance, and returns the three inputs `createPermDock` takes. `sources.memberships.version` calls `authz_version_for` (or the record already read), so `claimsFirst(sources.memberships, { onStale: "reread" })` checks the token's freshness with one cheap call per request and reads `subject_for` only when the token is behind. When the version function does not exist yet, `version` falls back to `subject_for`; regenerate the hook file to get it. Pass `policy` when the app has custom roles: `customRoles` then reads nothing while every role the subject holds is declared, and `version` calls `subject_for` instead of `authz_version_for` when the token claims a role the policy does not declare, because the custom roles need that record anyway. A current token with only declared roles costs one version call; a custom-role token costs one `subject_for` call, fresh or stale. `sources.memberships.list(query)` is the member list `countHolders` and `whoCan` read, so a role change can pass the holders `decideRoleChange` needs:

```ts
import { claimsFirst, countHolders, createPermDock } from "permdock";
import { postgrestSources, subjectFromSupabase } from "permdock/supabase";

const sources = postgrestSources(admin, {
  api: { schema: "public", prefix: "permdock_" }, // the supabase.hook.api setting
  policy,
});

// a request: the token's memberships, the database's when it was truncated
const subject = subjectFromSupabase(claims);
const permdock = await createPermDock(policy, subject, {
  memberships: claimsFirst(sources.memberships),
  ...(subject.principal === null
    ? {}
    : { customRoles: sources.customRoles(subject.principal) }),
});

// a job acting for a stored user
const actor = await createPermDock(policy, await sources.subject(userId), {
  customRoles: sources.customRoles({ id: userId }),
});

// the holders a role change counts
const holders = await countHolders(sources.memberships, {
  scope: "organization",
  id: organizationId,
  role: roles.owner,
});
```

`api` sets the defaults of `schema`, `fn`, `membersFn` and `versionFn` to the wrappers `supabase.hook.api` writes; any of the four still overrides it. Without `api` they default to the hook schema's own functions, for a client that can reach it. `sources.memberships` answers `membershipsFor` and `version`, so `claimsFirst` freshness works without a `SqlQuery`. `sources.customRoles(principal)` returns the custom roles that principal holds; a role-management page that needs every role of a tenant composes [`customRoleSource`](/docs/concepts/extension-interfaces#membershipsource-and-rolesource) over its own read. `sources.subject(userId)` is the anonymous subject for a suspended or unknown user, and a failed call rejects, which `createPermDock` turns into a denial.

## Generate [#generate]

```ts
// permdock.config.ts
import { sources } from "./src/memberships.ts";

export default defineConfig({
  policy: "./src/policy.ts",
  supabase: {
    hook: {
      memberships: sources(),
      attrs: {
        table: "profiles",
        columns: ["region", "clearance", "app_metadata.regions"],
      },
    },
  },
});
```

```bash
permdock supabase hook generate --out supabase/migrations/20260929_permdock_hook.sql
permdock supabase hook generate --active-from profiles.active_organization_id --budget 2048
permdock supabase hook generate --check
```

The output is one idempotent migration:

* `custom_access_token_hook(event jsonb)`, `stable`, `search_path = ''`, which writes:
  * `roles` (every global role) and `user_role` (a string for one role, an array for several; absent for none, so `app_metadata` still applies) from `supabase.hook.roles`, by default `rls.roles`, else `<schema>.user_roles (user_id, role)`. A `role` of `{ through: 'roles', on: { role_id: 'id' }, column: 'key' }` reads the key through a roles table, which also gets the auth-admin read. When `rls.customRoleWrites.roles` names that table with a `tenant` column, only its rows without a tenant are global roles;
  * `memberships`: a `union all` over every source, in the canonical `{ scope, id, within?, roles, via?, expiresAt?, managedBy?, entitlements?, keep? }` form;
  * the active tenant claim (`rls.tenantClaim`, default `tenant_id`), only when the user holds a membership in it. The generated RLS helpers narrow to it unless `rls.tenants` is `'all'` ([active tenant](/docs/cli/rls#active-tenant)). The active first-scope id comes from `app_metadata.active_<first scope>` by default; `--active-from` (or `supabase.hook.activeFrom`) takes `app_metadata.<key>`, `<table>.<column>` joined on `id`, or `{ table, id, column }`;
  * `attrs`: the allow-listed attribute columns and `app_metadata` keys (below), never other columns;
  * `authz_ver`: the user's authorization version (below);
  * the claims other packages own, from `supabase.hook.claims` (below).
* The `supabase_auth_admin` grants: `usage` on the schema, `execute` on the hook, `select` and a read policy on every table the hook reads (sources, their suspension tables, global roles, every roles table and user table a `through` references, the `attrs` table, version). `execute` is revoked from `authenticated`, `anon` and `public`. Nothing is granted to `service_role`, except to the `supabase.hook.api` wrappers when you set it.
* `permdock_authz_version (user_id, version)` and the `permdock_bump_authz_version` trigger on every source table and the global-roles table. With `through` on the global roles or on a source, `permdock_bump_authz_version_role_keys` on each referenced roles table bumps every user who holds a renamed role, globally or through a membership, when its key or id changes, since a rename changes their `roles` and `memberships` claims. A source that reads its user through another table gets `permdock_bump_authz_version_member_users` instead, which bumps the users its old and new rows reference, and the referenced table gets `permdock_bump_authz_version_linked_users` on updates of its user and id columns and on delete, which bumps both the old and the new user when a profile is re-linked to another login. That trigger skips a login that no longer exists in `auth.users`, so deleting a user whose profile is set to `null` still succeeds.
* `permdock_bump_authz_version_for(p_users uuid[])`, which bumps each listed user once, for membership data the hook does not read (below).
* The `permdock_protect_managed` trigger on sources with `managedBy`.
* The `config.toml` block, printed and repeated as a comment:

```toml
[auth]
jwt_expiry = 900

[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"
```

`jwt_expiry = 900` bounds how long a demotion can take to reach a token: 15 minutes, set with `supabase.hook.jwtExpiry`. The command refuses a source whose scope the policy does not declare, a single-scope source without a `within` column for every ancestor (such a membership grants nothing), a non-positive budget and unsafe identifiers.

## Declarative schemas [#declarative-schemas]

With pg-delta (`[experimental.pgdelta] enabled = true`), `supabase db schema declarative sync` keeps grants and policies, so the hook file keeps its grants. Without `--out`, `hook generate` writes it to `supabase/schemas/<schema>/functions/custom_access_token_hook.sql`, under `declarative_schema_path` when that is set.

A project that writes migrations with `supabase db diff` instead cannot keep every privilege in the hook file: `db diff` drops schema and function privileges, so the hook would land executable by `public` and not by the auth server. `--grants-out` moves those statements to a migration of their own and leaves a comment in the hook file that says where they went:

```bash
permdock supabase hook generate --out supabase/schemas/identity/056_permdock_hook.sql --grants-out -
supabase db diff -f permdock_hook
supabase migration new permdock_hook_grants
permdock supabase hook generate --out supabase/schemas/identity/056_permdock_hook.sql \
  --grants-out supabase/migrations/<timestamp>_permdock_hook_grants.sql
```

The grants file starts with `-- permdock:grants v1 schema=<schema>` and holds `usage` on each schema the hook reads, `execute` on the hook and on each `supabase.hook.claims` function, the revoke on the hook from `authenticated`, `anon` and `public`, the revokes on the version and protection trigger functions, and the revoke on the hook schema from `public`. The `select` grants and `permdock_auth_admin_read_*` policies on the tables the hook reads stay in the hook file, because `db diff` diffs table privileges and policies and would drop them from a migration that held them. `--check` compares both files. `permdock rls generate --split helpers,policies,hook` writes the hook as one part next to the helpers and policies and takes the same `--grants-out` ([declarative schemas](/docs/cli/rls#declarative-schemas)). Doctor PD042 errors while no migration from the one that creates the hook on carries the grants.

## Attributes [#attributes]

`supabase.hook.attrs` fills the `attrs` claim that [attribute conditions](/docs/adapters/rls#subject-attributes-abac) read: `where: { region: principal.claims.attrs.region }` compiles to `region = ((select auth.jwt()) -> 'attrs' ->> 'region')` in RLS and reads `principal.claims.attrs.region` from `subjectFromSupabase` in the app, from the same claim.

```ts
attrs: {
  table: 'profiles',          // joined on `id` (set `id` for another column)
  columns: ['region', 'clearance', 'app_metadata.regions'],
}
```

* A plain entry is a column of `table`; `app_metadata.<key>` reads `auth.users.raw_app_meta_data`, which only the server writes. Each entry's last segment is the claim key, and must match `^[A-Za-z_][A-Za-z0-9_]*$` so a nested ref can name it.
* Only server-owned values may become attributes. `generate` refuses `user_metadata` and `raw_user_meta_data` in any form, `auth.users` as the table (use `app_metadata.<key>`), prototype keys and a key named twice.
* The migration starts with a guard: when `anon` or `authenticated` can insert or update any listed column (`has_column_privilege`, which counts table-level and `public` grants), it raises `42501` and installs nothing. Grant clients column-level `update` on other columns only. `permdock doctor` PD028 reports the same from your migrations, before you run them.
* The hook drops any `attrs` the incoming claims carry and writes its own, so an attribute is never whatever the client sent.

## Claims other packages own [#claims-other-packages-own]

`supabase.hook.claims` adds claims PermDock does not own to the one hook Supabase allows, for example per-tenant plan features from a billing module. PermDock owns the hook and the claims the SQL helpers read; the other package owns its claim and the function that computes it ([better-supabase](/docs/adapters/better-supabase#memberships) shows one such split):

```ts
supabase: {
  hook: {
    memberships: sources(),
    claims: { features: 'public.feature_claims' },
  },
}
```

* Each value is a schema-qualified function `(user_id uuid) returns jsonb`; the hook runs with `search_path = ''`, so an unqualified name is refused. The hook sets the claim to the result, and a `null` result leaves the claim out.
* A name PermDock or Supabase Auth writes is refused: `roles`, `user_role`, `memberships`, `memberships_truncated`, `attrs`, `authz_ver`, the tenant claim, and `sub`, `aud`, `role`, `exp`, `iat`, `iss`, `aal`, `amr`, `session_id`, `is_anonymous`, `email`, `phone`, `app_metadata`, `user_metadata`.
* The hook drops each named claim from the incoming claims before it calls the function, so the value is always the function's.
* The claims sit outside the size budget: the budget decides which memberships a token keeps, and a claim the hook cannot size must not push memberships out. Keep each one small; the function owns its size.
* A suspended user gets none of them, and the function is not called. A user with no memberships still gets them.
* The migration grants `supabase_auth_admin` `usage` on each function's schema and `execute` on the function. A function that raises makes the hook fail, so Supabase Auth issues no token.
* A function that needs the user's memberships calls `<schema>.member_<scope>_ids_for(user_id)` instead of reading the membership tables itself, so it agrees with the hook and RLS about expiry and suspension:

  ```sql
  create function public.feature_claims(user_id uuid) returns jsonb
  language sql stable as $$
    select jsonb_object_agg(f.organization_id, f.features)
    from public.organization_features f
    where f.organization_id in (select permdock.member_organization_ids_for(user_id))
  $$;
  ```

  `permdock rls generate` writes these helpers ([SQL helper contract](/docs/adapters/rls#sql-helper-contract)); with `supabase.hook.claims` set, the hook migration (or the `--grants-out` file) grants `supabase_auth_admin` `execute` on each, so apply the helpers migration first.

  **Why.** Auth runs the hook as `supabase_auth_admin` before a token exists, so `auth.uid()` and `auth.jwt()` are empty and `member_<scope>_ids()` returns nothing. A copy of the membership query in each package drifts from the hook's as soon as a source gains expiry or suspension; one helper that takes the user keeps a single rule. The grant only exists with `hook.claims`, so a hook with no claims of its own still installs before the helpers.

## Checks before the hook [#checks-before-the-hook]

`supabase.hook.before` runs checks of your own, or of another package, inside the one hook Supabase allows, before PermDock computes any claim. A typical check refuses a password sign-in for a user whose email domain enforces single sign-on, or a sign-in to a locked account:

```ts
supabase: {
  hook: {
    memberships: sources(),
    before: 'auth_checks.require_sso', // or a list, run in order
  },
}
```

```sql
create function auth_checks.require_sso(event jsonb) returns jsonb
language plpgsql stable as $$
begin
  if auth_checks.sso_required(event ->> 'user_id')
    and event ->> 'authentication_method' is distinct from 'sso/saml' then
    return jsonb_build_object('error', jsonb_build_object(
      'http_code', 403, 'message', 'Sign in with single sign-on'));
  end if;
  return event;
end;
$$;
```

* Each entry is a schema-qualified function `(event jsonb) returns jsonb`; the hook runs with `search_path = ''`, so an unqualified name is refused.
* The hook calls each one first, in order, with the event it received. A result with an `error` key is returned as it is, so Supabase Auth refuses the token with that status and message, and no later function and no PermDock claim runs. A `null` or non-object result is refused the same way, with status 500 and `<function> returned no event`.
* Otherwise the result is the event the hook continues with, so a check may also change incoming claims. PermDock still drops and writes the claims it owns afterwards, so a check cannot set `roles`, `memberships` or the tenant claim.
* The migration (or the `--grants-out` file) grants `supabase_auth_admin` `usage` on each function's schema and `execute` on the function. Grant it whatever the function reads.
* `permdock supabase inspect` lists the functions as `hook.before` in the manifest.

**Why.** Supabase Auth calls one access token hook. A package that has to refuse a sign-in, such as single sign-on enforcement, would otherwise need its own hook and lose PermDock's claims, or ask the application to hand-edit the generated function on every regeneration. A check that runs first and can only refuse or pass the event keeps one hook and leaves the claims PermDock owns to PermDock.

## Claim validation [#claim-validation]

`supabase.hook.validate: true` makes the hook check its own claims against `supabase-claims-v1.json` with pg\_jsonschema, as the last step before it returns:

```sql
if not extensions.jsonb_matches_schema('<permdockClaims>'::json, claims) then
  claims := claims - 'user_role' - 'roles' - 'memberships' - 'memberships_truncated' - 'attrs' - 'authz_ver' - 'tenant_id';
end if;
```

* The schema is the `permdockClaims` definition with its `membership` entries, `keep` included.
* On a mismatch the hook drops every claim it owns and returns the event. The user signs in with no roles or memberships, so every PermDock check denies; sign-in itself does not fail.
* A mismatch comes from data the hook did not produce, such as an incoming tenant claim of the wrong type that no active tenant replaced. `hook.claims` entries belong to other packages and stay.
* The migration runs `create extension if not exists pg_jsonschema with schema extensions` and grants `supabase_auth_admin` `usage` on `extensions` and `execute` on `extensions.jsonb_matches_schema(json, jsonb)`.
* Off by default: the check costs one schema compile per token.

**Why.** A token whose PermDock claims break the schema would fail later, in every reader at once. Dropping them at the source turns that into a sign-in with no access, which is the fail-closed outcome, and keeps a bad row from locking a user out with an Auth error.

## Size budget [#size-budget]

Claims travel in the session cookie and on every request. The budget, `supabaseMembershipsBudget` bytes of JSON (1024 by default, measured with `octet_length`; set with `--budget` or `supabase.hook.budget`), covers `attrs` and `memberships` together. `attrs` is sized first: when it alone exceeds the budget it is left out whole and `memberships_truncated: true` is set, so attribute conditions deny rather than read half an object. The hook then adds memberships in order, the active tenant's first, then source order, and stops before the two together would exceed the budget, again setting `memberships_truncated: true`.

`subjectFromSupabase` surfaces the flag as `principal.membershipsTruncated`. With `claimsFirst`, the subject then reads every membership from the sources, so a user in 40 organizations still gets all 40 on the server while the token stays small. RLS in `jwt` mode only sees the kept entries; use `database` mode ([RLS](/docs/cli/rls)) when members routinely exceed the budget.

## Authorization version [#authorization-version]

A token is a copy, and a copy can be stale: a demoted admin keeps their token until it expires. For most permissions `jwt_expiry` bounds that. For sensitive ones (removing a member, creating a payout), list them in the policy:

```ts
definePolicy(permissions, {
  fresh: [permissions.member.remove, permissions.payout.create],
  // ...
});
```

The generated triggers bump `permdock_authz_version.version` for the affected user on every insert, update or delete in a source table or the roles table, and the hook writes the current value as `authz_ver`. `authzVersion({ query })` reads the same table. When the subject's memberships come from the token (`claimsFirst` without truncation), `createPermDock` compares the two. If the token is behind, or either side is missing, the subject is `stale` and every `fresh` permission denies with reason `stale-credentials`; other permissions still use the token. Memberships read live from a source are never stale. A stale subject's snapshot omits allows for `fresh` permissions, so the browser denies them too. A suspension change does not bump the version; RLS checks suspension live instead ([RLS](/docs/cli/rls)).

A table that changes what a user may do without being a hook source, such as better-supabase's `entitlement_members`, bumps its users with `permdock_bump_authz_version_for(array[...])` from its own trigger. The function is `security definer`, and `execute` is revoked from `public`, `anon` and `authenticated` and granted to no one, so only the function's owner can call it: a `security definer` trigger owned by the table owner, or a migration. The manifest names it in `authzVersionBump`.

## Memberships the identity provider owns [#memberships-the-identity-provider-owns]

A membership provisioned by SCIM belongs to the IdP: an edit in the app would be undone by the next sync. Such memberships carry `managedBy: 'idp'`. [`directoryMembershipSource`](/docs/adapters/scim) sets it on every group membership, and the SQL sources read it from `managedBy`. `isExternallyManaged(membership)` tells a member list to render the row read-only, and [`decideRoleChange`](/docs/concepts/ownership) refuses a change whose `target.managedBy` is `idp` with reason `externally-managed`. In the database, `permdock_protect_managed` raises `42501` when `anon` or `authenticated` inserts, updates or deletes an IdP-owned row; the server and the SCIM relay, which connect with their own role, are unaffected.

## Plans and seats [#plans-and-seats]

`entitlements` on `createPermDock` is an `EntitlementSource`: `entitlementsFor(principal, { tenant })` returns plan names for the active tenant, merged into `principal.plans`, so `plan('<name>')` grants apply. It is read for the validated active tenant only; a requested tenant without a membership gets none. `fromStripeEntitlements({ stripe, customer })` reads Stripe's active entitlements (`stripe.entitlements.activeEntitlements.list`, every page) for the tenant's Stripe customer and returns their `lookup_key`s. `memoryEntitlementSource` is the in-process default.

A seat belongs to one membership, not the tenant: a Dev Mode seat in one organization says nothing about another. `Membership.entitlements` holds seats (from the sources' `seats` column), and a `plan()` grantee matches a seat only through a membership that applies under the active tenant.

## Manifest [#manifest]

`permdock supabase inspect --out permdock.manifest.json` writes what the hook and helpers expect as one JSON file, and `--check` fails CI when it falls behind the config. It is the stable contract for packages that build on the hook: better-supabase reads it instead of PermDock's config or its generated SQL.

| Field | What a reader learns |
| --- | --- |
| `hook`, `markers` | Where `custom_access_token_hook` lives and the majors of the `-- permdock:hook` / `-- permdock:grants` lines its migrations start with |
| `claims`, `tenantClaim`, `budget`, `authzVersion`, `authzVersionBump` | Every claim the hook writes, who owns it, what the budget measures, and the function that bumps `authz_ver` for a list of users |
| `helpers`, `rls` | The helper schema, each helper's arguments, return type and the roles that may execute it, the RLS mode (`jwt` or `database`) and each scope's id type |
| `memberships` | Each source's table and the columns (or fixed values) for user and role (each with the table it reads through), scope, id, `within`, `via` and expiry |
| `rls.memberships` | The same shape for the tables `member_<scope>_ids_for` reads, which differ from the hook's when `rls.memberships` or `rls.membershipSources` is set |
| `rls.helpers` (`_for` and assignment forms) | The `_for` helpers trusted SQL calls for a stored user, and `permdock_can_assign_any` with the other assignment checks when a role declares `assigns` |
| `rls.customRoles`, `rls.roles`, `rls.suspension`, `rls.assignments` | Whether custom roles live in tables, the global-roles table, the suspension tables, and the tables the assignment triggers guard |
| `decidingColumns` | Every `schema.table.column` a membership or an `attrs` claim is computed from, which clients must not be able to write |

The format, its JSON Schema (`schemas/supabase-manifest-v1.json`) and the versioning rule are on [wire formats](/docs/concepts/wire-formats#supabase-hook-manifest); `supabaseHookManifestFixture` in `permdock/testing` is a sample to test a reader against.

## Why [#why]

* **A manifest, not a shared config.** A package next to the hook needs what the generated SQL reads, not how PermDock's config spells it. A versioned file that only gains fields keeps the two packages on separate release schedules, and a committed copy lets its doctor check a project without loading the PermDock config.
* **One definition, two readers.** The sources run the exact `select` the hook compiles, and the integration suite checks that the claim equals what `composeMemberships` returns for the same user. A hand-written hook next to a hand-written source is two definitions of who belongs where.
* **The active scope first, then truncation.** A token that cannot hold every membership should hold the ones the next request needs. Truncating silently would turn a large customer into a user with random missing access; the flag makes the server read the rest.
* **Roles are read by key, through a roles table when the app has one.** Role grants, `role_permissions` seeds, the `roles` claim and each membership's `roles` all name roles by key, while apps that manage roles in a UI usually keep `user_roles.role_id` and `organization_users.role_id` referencing a `roles` table. Copying keys into those tables would make a rename a data migration; joining at read time keeps one source of truth, and the version trigger on `roles` makes a rename reach existing tokens at the next `fresh` check. Global roles and membership sources take the same `{ through, on, column }` shape, so one roles table serves both and gets one trigger.
* **A membership's user can be read through a profile table.** Portal contacts are often rows of a contact or profile table that a login is attached to later, or moved between logins. Copying `user_id` onto every membership row would make each re-link a fan-out update that the app has to keep consistent; reading it through the profile keeps one place to change, and the trigger on that column bumps both logins, so the old one loses the memberships and the new one gains them at the next `fresh` check.
* **A version, not a shorter expiry, for sensitive verbs.** Dropping `jwt_expiry` to a minute taxes every request to protect a few. The version costs one indexed lookup per request that uses `claimsFirst` with `version`, and only `fresh` permissions depend on it. Missing either side counts as stale, because a check that passes when it cannot compare fails open.
* **Attributes are server-owned or absent.** An attribute that decides row access and that the user can edit is a self-grant. Refusing `user_metadata` catches the obvious case; the migration guard catches a `profiles` table with a broad `update` grant, which is the common one.
* **IdP ownership is enforced where edits happen.** The member list, `decideRoleChange` and the database all refuse, so a client that skips the UI still cannot edit a SCIM-provisioned row.
* **Grants in their own migration for `db diff`.** `db diff` reads the schema files back from a shadow database and leaves out what it cannot diff for the auth role. A separate, hand-created migration keeps those statements in the history in the right order; putting them in the schema file would make them silently disappear, and a hook the auth server cannot call fails every sign-in. pg-delta diffs grants, which was checked against `declarative sync` in Supabase CLI 2.119, so under pg-delta the grants stay in the hook file.
* **`uid` is a uuid.** Supabase Auth issues uuid user ids, so the hook casts `user_id` once and a malformed one fails the sign-in. Each `%type` variable is then assigned from a uuid, which `supabase db lint` accepts.
* **Plans from billing, seats per membership.** Stripe recommends reading entitlements as a projection of billing state; a per-membership seat keeps "which products this person uses here" apart from permissions and from other tenants.

## Related [#related]

* [Supabase provider](/docs/adapters/supabase): `subjectFromSupabase`, `supabaseRls`, the RBAC scaffold.
* [RLS](/docs/cli/rls): helpers, `jwt` and `database` modes, suspension.
* [Tenancy](/docs/concepts/tenancy) and [scopes](/docs/concepts/scopes): memberships, sources, the active tenant.
* [supabase CLI command](/docs/cli/supabase).
