# Supabase

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

The permdock/supabase provider maps Supabase JWT claims to a PermDock subject, scaffolds the authorize() RBAC tables and hook, and pairs them with the per-statement RLS helpers (permdock_has, permitted_<scope>_ids and member_<scope>_ids per named scope) that generated policies call.

`permdock/supabase` is a provider: it does not check permissions itself, it produces the subject PermDock checks against, from a Supabase session or access token. It also owns the Supabase-specific half of [RLS generation](/docs/adapters/rls): the `user_roles` / `authorize()` / hook scaffold from Supabase's RBAC guide, over the same `role_permissions` table the generated RLS helpers (`permdock_has`, `permitted_<scope>_ids`, `member_<scope>_ids`) read.

## Purpose [#purpose]

Supabase apps have two enforcement points, the application and Postgres RLS, and one identity source, the JWT. Supabase's recommended RBAC ([Custom Claims and RBAC](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)) stores roles in `user_roles`, permissions in `role_permissions`, injects `user_role` into the JWT through a `custom_access_token_hook`, and evaluates `authorize('channels.delete')` inside policies. That is a named-permission catalog in disguise. The provider aligns PermDock with it: PermDock's `permissions` becomes the source of `role_permissions`, `subject.roles` comes from the JWT claim, and `authorize()` answers with PermDock keys. Generated policies do not call `authorize()` per row: they call the `permdock_has` / `permitted_tenant_ids` helpers, which Postgres runs once per statement ([per-statement helpers](/docs/adapters/rls)).

## API [#api]

```ts
import { createPermDock } from "permdock";
import { defineConfig } from "permdock/cli";
import { subjectFromSupabase, supabaseRls } from "permdock/supabase";

// server: from claims the Supabase client verified (JWKS locally with asymmetric keys, Auth server with the legacy secret)
const { data, error } = await supabase.auth.getClaims();
const permdock = await createPermDock(
  policy,
  subjectFromSupabase(error ? null : data.claims, {
    roles: "user_role", // hook-injected; global roles
    tenant: "tenant_id", // hook-injected; the active tenant
    memberships: "memberships", // hook-injected [{ scope, id, within?, roles }] from your tables, optional
  }),
);

// permdock.config.ts: RLS compile hints (not on definePolicy)
export default defineConfig({
  policy: "./src/policy.ts",
  rls: supabaseRls({
    roleClaim: "user_role",
    tenantClaim: "tenant_id",
    memberships: {
      table: "organization_members",
      tenant: "organization_id",
      user: "user_id",
      role: "role",
    },
  }),
});
```

* `subjectFromSupabase(claims, options)` takes the verified claims from `getClaims()` (or `null`) and returns a subject whose principal is `{ id: sub, roles, tenant, memberships, assurance, claims }`: `id` is the `sub`; `roles` is derived from the configured role claim (default `user_role`, top-level as the hook writes it or under `app_metadata`) and holds global roles; `tenant` comes from the configured tenant claim (default `supabaseTenantClaim`, `tenant_id`, hook-injected or under `app_metadata`; keep it equal to `rls.tenantClaim`, which `permdock doctor` PD038 checks); `memberships` comes from a hook-injected claim of `{ scope, id, within?, roles, via?, expiresAt? }` entries (the [named scopes](/docs/concepts/scopes) form; `{ tenant, team? }` is still read) when the hook writes one, or from a `MembershipSource` over your tables when it does not; `issuer` is `iss`; `assurance.acr` is `aal`; `claims` exposes `app_metadata` values. `user_metadata` is never read because it is user-writable. It never throws; malformed or `anon` claims yield the anonymous subject. Details in "Verified material" below.
* `options.schema` (any Standard Schema, for example a Zod object) validates and types the custom claims before they reach `principal.claims`; an invalid claim set drops `claims` with a development warning, never the subject.
* `postgrestSources(client, { schema?, fn?, membersFn?, versionFn?, policy? })` reads a stored user's roles, memberships, held custom roles and authorization version through supabase-js from the `subject_for(p_user)` function the hook file defines, for a backend with no `SqlQuery`: `memberships` for `claimsFirst` (with `version`, one `authz_version_for(p_user)` call, so a fresh token costs no full read, and `list`, the members of one instance from `members_of(p_scope, p_id)`, for `countHolders` and `whoCan`), `customRoles(principal)` and `subject(userId)`; with `policy`, custom roles are read only for a subject that holds one, and a token that claims one has its subject read in one `subject_for` call ([a stored user over PostgREST](/docs/adapters/supabase-hook#a-stored-user-over-postgrest)).
* `supabaseApprovalStore(client, { schema?, ttl?, onOpen? })` is an `ApprovalStore` over the table and functions `rls.approvals` generates ([a generated store](/docs/adapters/approvals#a-generated-store-for-postgres-and-supabase)).
* Both take the client as a `SupabaseRpcCaller`: anything with `schema(name).rpc(fn, args)`, including a supabase-js client typed with a generated `Database` (`SupabaseClient<Database>`), whose `rpc` accepts only the functions that `Database` declares, so it passes without a cast or a second untyped client.
* `supabaseRls({ realtime, storage })` adds policies for private Realtime channels and Storage buckets, so a channel such as `org:<id>:chat` or an object under `<id>/` follows the same permission as your tables ([Realtime and Storage](/docs/cli/rls#realtime-channels-and-storage-buckets)).
* `supabaseClaims({ tenantClaim? })` is the claim contract as a Standard Schema v1 object, typed `SupabaseClaims<TenantClaim>` with `SupabaseMembershipClaim` entries. It needs no validation library, and `tenantClaim` defaults to `tenant_id`. See "Claims schema" below.

### Claims schema [#claims-schema]

`supabaseClaims()` validates the claims the hook writes against the same rules as `schemas/supabase-claims-v1.json`: `user_role`, `roles`, `memberships` (`scope`, `id`, `within`, `on`, `tenant`, `team`, `roles`, `via`, `expiresAt`, `grantedBy`, `reason`, `managedBy`, `entitlements`, `keep`, `grants`), the tenant claim, `attrs`, `authz_ver` and `memberships_truncated`, each at the top level or under `app_metadata`. It also checks `client_id`, `scope` and `act`. It is loose: a claim it does not name passes through unchanged. A known claim with the wrong shape is an issue with a `path`, so a library that validates sessions with it rejects the whole token. `subjectFromSupabase` stays lenient on its own and only drops the one bad membership.

`extend(appSchema)` adds the application's claims. Both schemas validate the same claims, their issues are combined, and the app's output is merged over the base; the result is a Standard Schema again, typed `SupabaseClaims & InferOutput<typeof appSchema>`, so it chains and accepts Zod 4, valibot or any other Standard Schema:

```ts
import { supabaseClaims } from "permdock/supabase";
import { z } from "zod";

const claims = supabaseClaims().extend(
  z.object({ datetime_preferences: z.object({ timezone: z.string() }) }),
);
const result = await claims["~standard"].validate(verifiedClaims);
```

It stays synchronous unless the app schema is asynchronous. `supabaseClaimFixtures` in `permdock/testing` all pass it, from `full` (every field, `memberships_truncated` and a `hook.claims` claim) to `portalContact`, `oauthClient`, `actChain`, `supportSession`, `supportSessionReadOnly`, `impersonation` and `anonymousSignIn`. `supabaseClaimVectors` holds the same cases as a [Supabase SDK conformance vector file](https://github.com/supabase/sdk/tree/main/packages/capability-matrix) for `auth.session.get_claims`. Each case has `name`, `input` (`{ claims, options }`) and `expected` (the subject fields). A port of `subjectFromSupabase` to Swift, Python or Dart can check its mapping against `JSON.stringify(supabaseClaimVectors)`.

* `supabaseRls(options)` is the `rls` value in `permdock.config.ts`. It tells `permdock rls` how `principal.id` (`(select permdock.permdock_user_id())`, which answers as `auth.uid()` does and reads an empty `sub` as no user), `principal.claim.<name>` (`(select auth.jwt()) ->> 'claim'`), `principal.roles` (the `permdock_has` helper) and tenant-scoped roles (the `permitted_tenant_ids` helper, over the membership table or the `memberships` claim) compile; `tenantType` sets the tenant column type the claim is cast to (default `uuid`) ([tenancy](/docs/concepts/tenancy), portable compilation). `memberships` is one table, or `{ scopes, tenant, team, resource }` with a table per [named scope](/docs/concepts/scopes). `suspension` names the users and scope-instance tables whose `disabledAt` or `status` column suspends a row ([RLS](/docs/cli/rls)).
* `exchangeCapability(subject, { key, alg?, kid?, issuer?, ttl? })` exchanges a verified link subject (from `subjectFromCapability`) for a short-lived Supabase access token: `role: 'anon'`, the capability under a `capability` claim, `iat`, `exp` (the `ttl`, default 300 seconds and at most 3600, never past the capability's expiry) and no `sub`, because `auth.uid()` casts `sub` to a uuid. `key` is a private JWK imported into the project's [JWT signing keys](https://supabase.com/docs/guides/auth/signing-keys) with `alg: 'ES256'` (the default) or `'RS256'` and its `kid`, or `{ secret }` with `alg: 'HS256'` named for a project on the legacy JWT secret. It returns `undefined` for any subject that is not a live link, and never mints `service_role`. `permdock rls generate --capabilities` emits the policies that read the claim ([link capabilities](/docs/concepts/capabilities)).
* `authorizeSql({ schema, authorize, tenant })` is the `authorize(requested_permission, requested_tenant text default null)` function SQL. `authorize: 'database'` (the default) reads `user_roles`, and for a tenant request the membership table passed as `tenant`, whose `role` is a key column or `{ through, on, column }` for a role id read through a roles table. `authorize: 'jwt'` reads the hook-injected `user_role` (falling back to `app_metadata.user_role` when the top-level claim is `null`) and `memberships` claims. A tenant request with no memberships source returns `false`; it is never answered from global roles. `permdock rls generate --rbac supabase` emits the same function. It reads `role_permissions` by `permission`, keeps only `effect = 'allow'` rows of the matching scope, and ignores row conditions: it is for hand-written policies and RPCs. With `customRoles: { declared }` (what `generate --rbac supabase --custom-roles` passes), a tenant request also answers from the membership's [custom roles](/docs/concepts/custom-roles): the `custom_role_*` tables in `database` mode, the `memberships[].grants` claim in `jwt` mode, both through the `permdock_custom_keys` function and the ceiling view the same command generates. `declared` role names never resolve as custom. With `suspension`, a suspended user, or a tenant request for a suspended instance of the first scope, answers `false` in both modes.
* `permdock rls generate --rbac supabase [--rbac-schema permdock] [--authorize database|jwt] [--memberships <table>:tenant,user,role]` (`--rbac-scaffold` is the older spelling) emits:
  * re-runnable enums `app_role` and `app_permission`;
  * table `user_roles(user_id, role)` (a user may hold several roles), with RLS enabled and no client grants;
  * the shared `role_permissions(role, permission, grant_key, scope, effect)` table and its seed rows, from role grants and top-level `definePolicy({ grants })` alike (every `rls generate` emits these, with or without `--rbac`);
  * the `permdock_has` helper and one `permitted_<scope>_ids` and one `member_<scope>_ids` per declared scope, `security definer` with `search_path = ''`, executable by `authenticated` only;
  * one `member_<scope>_ids_for(p_user uuid)` per scope with a membership source, for `supabase.hook.claims` functions, executable by no client role ([claims other packages own](/docs/adapters/supabase-hook#claims-other-packages-own));
  * `authorize()` as above, executable by `authenticated` only;
    Everything lands in the chosen schema. It emits no token hook: `permdock supabase hook generate` is the one generator of `custom_access_token_hook`, so one function writes `user_role`, `memberships` and the other claims ([Supabase token hook](/docs/adapters/supabase-hook)). The hook reads the same `user_roles` table by default.
* A global role's grant compiles to `(select "permdock".permdock_has('post.delete'))`; a tenant-scoped role's grant to `"orgId" in (select "permdock".permitted_tenant_ids('post.update#2'))`; conditions AND onto their branch, and one policy per table and command ORs the branches. `insert` policies carry only `with check`. No policy calls `authorize()` with a row column, which would run it once per row.

Claim mapping used by both the in-process subject and the RLS compiler:

| Subject field | Source in the JWT | In generated policies |
| --- | --- | --- |
| `principal.id` | `sub` | `(select permdock.permdock_user_id())`, which returns what `auth.uid()` returns and null for an empty `sub` |
| `subject.roles` (global) | `user_role` (from the Auth Hook) | `(select permdock_has('perm'))`, reading `user_roles` (database) or the claim (jwt) |
| `principal.tenant` (active tenant) | `tenant_id`: `app_metadata.tenant_id` or a top-level hook-injected claim | Narrows the tenant helper to that tenant; in a condition, `org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid` |
| `principal.memberships` | A hook-injected `memberships` claim, or a `MembershipSource` over your tables | `org_id in (select permitted_tenant_ids('perm'))`, reading `supabaseRls({ memberships })` (database) or the claim (jwt) |
| `principal.claim.plan` | `app_metadata.plan` or a top-level custom claim | `(select auth.jwt()) ->> 'plan'` |
| anonymous | no token, or `role = anon` | `auth.uid()` is NULL; `TO anon` grants only |
| a link holder | `role = anon` with a `capability` claim from `exchangeCapability` | `"id"::text in (select permdock_capability_ids('quote', 'guest', 'quote.read'))` in a `TO anon` policy, with `--capabilities` |

A single-tenant-per-user app (the common Supabase case) needs only `tenant_id` in the hook; the active tenant is the claim, and tenant-scoped roles compile to one equality per policy. A multi-organization user needs a membership table the hook or a `MembershipSource` reads, and the compiled policy joins it; `permdock rls generate` emits the `exists` form when `supabaseRls` names the table and refuses tenant-scoped grants when neither a tenant claim nor a table is configured (fail closed, not a permissive policy).

## Middleware: `permdock/supabase/middleware` [#middleware-permdocksupabasemiddleware]

Supabase's server packages compose through [`@supabase/middleware`](https://github.com/supabase/middleware): `defineMiddleware` produces a `withFoo(config)` entry that contributes typed keys to a shared `ctx`, declares the upstream keys it needs, and `pipeline([...entries], handler)` runs them in order. `permdock/supabase/middleware` is PermDock's entry for that pipeline. It is a separate subpath so `permdock/supabase` keeps zero peer dependencies; `@supabase/middleware` is the optional peer of the middleware entry only.

```ts
import { pipeline } from "@supabase/middleware";
import { withClaims } from "@supabase/server/middleware/claims";
import { subjectFromSupabase } from "permdock/supabase";
import { createPermDock } from "permdock/supabase/middleware";

const { withPermDock, permdockHandler, openapi } = createPermDock(policy, {
  subject: (ctx) =>
    subjectFromSupabase(ctx.jwtClaims, {
      roles: "user_role",
      tenant: "tenant_id",
    }),
});

// decide in the handler: ctx.permdock is the request-scoped instance
export default {
  fetch: pipeline([withClaims(), withPermDock()], async (req, ctx) => {
    const post = await loadPost(req);
    if (!ctx.permdock.can(permissions.post.update, post)) {
      return Response.json({ ok: false }, { status: 403 });
    }
    return Response.json({ ok: true });
  }),
};

// or guard the route: a denial short-circuits with RFC 9457 Problem Details
const publish = pipeline(
  [
    withClaims(),
    withPermDock({
      protect: permissions.post.publish,
      data: (_ctx, req) => loadPost(req),
    }),
  ],
  async () => Response.json({ ok: true }),
);

// AuthZEN evaluations for the caller's own claims
const evaluations = pipeline([withClaims()], permdockHandler());
```

* `createPermDock(policy, options)` takes the same options as the HTTP adapters (`subject`, `tenant`, `memberships`, `customRoles`, `store`, `sink`, `limits`, `pdp`, `otel`, `webBotAuth`, and the rest of `InstanceOptions`) and returns `withPermDock`, `permdockHandler` and `openapi`. `subject(ctx, request)` receives the pipeline context; the documented body is `subjectFromSupabase(ctx.jwtClaims, …)`. `tenant` may be a string or `(ctx, request) => string | undefined`; a requested tenant without a matching membership yields no tenant ([tenancy](/docs/concepts/tenancy)). `context(ctx, request)`, `onDenied` and `wrap` behave as on the [server kernel](/docs/adapters/server-kernel#options).
* `withPermDock()` declares `jwtClaims` as its prerequisite and contributes `ctx.permdock`, a frozen request-scoped `PermDock`. A pipeline that places it without an upstream contributor of `jwtClaims` (`withClaims`, `withRequiredClaims`, or `withSupabase`'s context) is a type error, not a runtime surprise.
* `withPermDock({ protect, data, trusted, oauthScopes })` runs the kernel's `protect` before the handler (`data`'s result is validated unless `trusted: true` marks it as a row the server loaded): `data(ctx, request)` loads the row (a `null` is a `404`), a denial is a `403` Problem Details body with `permission`, `denials` and `alternatives`, an `approval-required` outcome is a `403` carrying `approval` and the `PermDock-Approval` resume header is honoured ([approvals](/docs/security/approvals)). A pass contributes `ctx.permdock` as above. `oauthScopes` narrows which coarse OAuth scopes reach the handler: a delegated token holding none of them gets a `403` with `WWW-Authenticate: Bearer error="insufficient_scope"`. Without `protect`, `withPermDock({ oauthScopes })` checks no permission: an anonymous caller gets a `401`, and a signed-in one passes when it is not a delegated token or holds one of the scopes ([server kernel](/docs/adapters/server-kernel)).
* `permdockHandler()` returns a terminal handler for `POST` [AuthZEN evaluations](/docs/adapters/authzen) against the caller's own subject; the body's `subject` never overrides the verified claims.
* `openapi` is the same hook set the HTTP adapters expose for [`permdock openapi`](/docs/cli/openapi).
* A pipeline entry returns a contribution or a `Response`; it cannot wrap the handler. A `ctx.permdock.assert(...)` in the handler therefore throws out of the pipeline: catch it in the app's `fetch` and return `problemFromError(error)` from `permdock/server`, or use `decide` and answer yourself.
* `ctx.jwtClaims` is typed structurally as `SupabaseJwtClaims` (`sub`, `role`, `app_metadata`, index signature), so `@supabase/server`'s `JWTClaims` satisfies it without a peer dependency, and so does any other entry that contributes the same key. A `null` (no token, `publishable` or `secret` mode) reaches `subjectFromSupabase` and becomes the anonymous subject; an invalid token never reaches PermDock because `withClaims` answers `401` first.
* `with*` is the pipeline's naming convention (`withClaims`, `withPostgresClient`), adopted here so the entry reads like its neighbours. No other PermDock entry uses it ([naming](/docs/getting-started/naming)).

### With `@supabase/server` [#with-supabaseserver]

[`@supabase/server`](https://github.com/supabase/server) is Supabase's stateless server toolkit: header-based authentication for Edge Functions, Workers, Hono, Elysia, NestJS and H3, no cookies. Its core `withSupabase(config, handler)` verifies the bearer token against the project JWKS (asymmetric keys only; HS256 legacy tokens are rejected) and hands the handler a context with `jwtClaims` (the verified payload or `null`), `supabase` (an RLS-scoped client carrying the caller's token), `supabaseAdmin` (the bypass client, built lazily) and `authMode` (`user`, `publishable`, `secret`, `none`). Every one of those shapes is an input to `subjectFromSupabase`:

| Where the claims are | Pass to `subjectFromSupabase` | Notes |
| --- | --- | --- |
| `withSupabase({ auth: 'user' }, (req, ctx) => …)` | `ctx.jwtClaims` | `auth: 'user'` rejects token-less requests with `401` before the handler runs |
| A standalone `pipeline([withClaims()], …)` | `ctx.jwtClaims` | `withClaims` contributes `null` for anonymous callers; `withRequiredClaims` short-circuits them with `401` |
| Hono, H3, Elysia or NestJS through a bridge | `ctx.jwtClaims` | The same entries run inside the framework's middleware slot; see "Hono, H3, Elysia and NestJS" below |
| `authMode: 'publishable'`, or `'secret'` with a key `secretKeys` does not name | `null` | An API key is not a user. The subject is anonymous |
| `authMode: 'secret'` with a key named in `secretKeys` | `null` (not read) | `withPermDock` builds the declared `service` principal; see "Secret keys as service principals" below |

`ctx.supabase` and `ctx.postgres` (from `withPostgresClient`) are already RLS-scoped, so a PermDock decision in the handler and a generated policy in Postgres are two evaluations of one catalog. `fromSupabasePostgres(ctx.postgres)` turns either Postgres client into the `SqlQuery` that `fromTable`, `fromJunction` and `supabaseApprovalStore` take, so a membership lookup runs on the request's own transaction without another pool. `ctx.supabaseAdmin` and `withPostgresAdminClient` bypass RLS: every use belongs behind an explicit `permdock.assert(...)` in the same handler, and the [`permdock-audit`](/docs/cli/skills) skill flags any that is not. `withPostgresClient` sets `request.jwt.claims` and `set local role` to `authenticated` or `anon` per transaction and refuses any other role in the token, including `service_role`; that is the same preamble `permdock rls verify` runs, so a policy that passes the harness behaves the same under the client ([RLS adapter](/docs/adapters/rls)). For MCP servers behind Supabase Auth as the OAuth 2.1 authorization server, see [`withOAuthProtectedResource` on the MCP page](/docs/adapters/mcp).

### Secret keys as service principals [#secret-keys-as-service-principals]

`withSupabase({ auth: 'secret:<name>' })` verifies a Supabase secret key, but the key says nothing about which tenant it belongs to or what it may do. `secretKeys` declares that in code, per key name in `SUPABASE_SECRET_KEYS` (a single `SUPABASE_SECRET_KEY` is named `default`):

```ts
import { withSupabase } from "@supabase/server";
import { pipeline } from "@supabase/middleware";
import { createPermDock } from "permdock/supabase/middleware";

const { withPermDock } = createPermDock(policy, {
  subject: (ctx) => subjectFromSupabase(ctx.jwtClaims),
  secretKeys: {
    billing: {
      id: "svc_billing",
      tenant: "org_acme",
      roles: ["billing"],
      permissions: [permissions.invoice.read, permissions.invoice.create],
    },
  },
});

export default {
  fetch: pipeline(
    [withSupabase({ auth: ["user", "secret:billing"] }), withPermDock()],
    async (req, ctx) => {
      ctx.permdock.assert(permissions.invoice.create, await req.json());
      return withSubject(db, ctx.permdock, (tx) => createInvoice(tx));
    },
  ),
};
```

* The key becomes the subject a `service` [credential](/docs/concepts/credentials) resolves to: `{ id, kind: 'service', tenant }` with one `{ tenant, roles, via: 'credential' }` membership, and `delegation` scopes for `permissions`, so it can do at most what both its roles and its permissions allow. `principal.credential.name` is the key name.
* Only `authMode: 'secret'` with an `authKeyName` the map names, and no `jwtClaims`, takes this path. Every other key, every publishable key and a request that also carries a user token go through `subject` as before. A name that is not a key name (`*`) or an entry without `id` or `tenant` throws when `createPermDock` runs.
* In Postgres, `withPostgresClient` runs a key-only request as `anon`, so query through `withSubject` from `permdock/drizzle`, `permdock/kysely` or `permdock/prisma` on the app's own pool instead. For a credential principal `withSubject` writes the [`rls.apiKeys`](/docs/cli/rls#api-keys) claim, `{ "sub": "", "api_key": { "id", "tenant", "roles", "scopes" } }` with role `authenticated`, so the generated helpers hold the key to its tenant, roles and permissions. A permission entry limited to `ids` has no claim form and is left out of `scopes`. Never use `ctx.supabaseAdmin` or `withPostgresAdminClient` for these requests: they bypass RLS.

### Two error formats [#two-error-formats]

`@supabase/server` and `@supabase/middleware` entries answer their own failures (a missing token, an invalid JWT, a JWKS that cannot be fetched) with Supabase's error body, `{ "source", "code", "message", "hint", "docs", "details" }` and an `x-supabase-server-error` header naming the code. PermDock's denials (`withPermDock({ protect })`, `permdockHandler`, a caught `assert`) are [RFC 9457 Problem Details](/docs/standards/problem-details) with `application/problem+json`. One API can return both: branch on `content-type`, or on `x-supabase-server-error` for a `401` that never reached PermDock. A `withCors` entry ahead of both stamps its headers on either.

### Hono, H3, Elysia and NestJS [#hono-h3-elysia-and-nestjs]

`@supabase/server`'s framework adapters (`@supabase/server/adapters/hono`, `h3`, `elysia` and `nestjs`, with the context on `c.var.supabaseContext`) are deprecated since 1.9.0 and removed on 1 December 2026. Their replacement is a bridge: a few lines in the app that run a `pipeline` of entries inside the framework's middleware slot and publish every contribution on the framework's context. `withPermDock` is one of those entries, so the route reads `c.var.permdock` and `c.var.jwtClaims`:

```ts
import { withClaims } from "@supabase/server/middleware/claims";
import { Hono } from "hono";

import { toHono } from "./to-hono.ts"; // the bridge from Supabase's examples/frameworks/hono

const app = new Hono()
  .use("*", toHono([withClaims(), withPermDock()]))
  .patch("/posts/:id", async (c) => {
    const post = await loadPost(c.req.param("id"));
    return c.var.permdock.can(permissions.post.update, post)
      ? c.json({ ok: true })
      : c.json({ ok: false }, 403);
  });
```

* Copy the bridge from [`examples/frameworks`](https://github.com/supabase/server/tree/main/examples/frameworks) in `@supabase/server` (`hono`, `h3`, `elysia`, `nestjs`, `tanstack-start`). It seeds the context with `seedContext(c.env)`, buffers the body with `bufferRequest`, and returns the framework's response through the entries, so response-phase entries such as CORS still stamp headers. `apps/examples/supabase-middleware/src/to-hono.ts` is the Hono bridge with this repository's lint rules applied.
* Chain `.use()` into the routes: Hono types `c.var` only through the chained calls. Routes that need another entry list go in a sub-app mounted with `app.route()`.
* `permdock/hono` stays the choice for a Hono app that verifies tokens with [`permdock/jwt`](/docs/adapters/jwt) instead of `@supabase/server`. Both read the same claims; do not stack them on one route.

### Feature flags [#feature-flags]

PermDock ships no flag adapter. Two `@supabase/middleware` entries read `ctx.permdock` instead. `withFeatureFlag` from `@supabase/middleware/feature-flag` gates a route on a permission and answers a denial with its 404, which does not reveal that the route exists:

```ts
import { withFeatureFlag } from "@supabase/middleware/feature-flag";

const reviewQueue = pipeline(
  [
    withClaims(),
    withPermDock(),
    withFeatureFlag({
      name: "review-queue",
      evaluate: (_req, ctx) =>
        permdockOf(ctx)?.can(permissions.post.review) === true,
    }),
  ],
  handler,
);
```

`evaluate` receives the upstream context as `BaseContext`, so read `ctx.permdock` through a type guard (`permdockOf` in `apps/examples/supabase-middleware/src/flags.ts`) rather than a cast. Use `withPermDock({ protect })` instead when the caller should get Problem Details with the denial reason.

`withOpenFeature` from `@supabase-labs/middleware-openfeature` resolves flags into `ctx.flags` and never gates. Build its evaluation context from the subject PermDock resolved from the verified claims, not from a header the client sets:

```ts
import { withOpenFeature } from "@supabase-labs/middleware-openfeature";

const flags = pipeline(
  [
    withClaims(),
    withPermDock(),
    withOpenFeature({
      client: OpenFeature.getClient(),
      flags: { "beta-editor": false },
      context: (_req, ctx: { readonly permdock: PermDock }) => {
        const user = ctx.permdock.subject.principal;
        return {
          targetingKey: user?.id ?? "anonymous",
          ...(typeof user?.tenant === "string" ? { tenant: user.tenant } : {}),
        };
      },
    }),
  ],
  async (_req, ctx) => Response.json(ctx.flags),
);
```

A flag decides what a caller sees; a permission decides what the caller may do. Check the permission in the handler even when a flag admitted the request.

### With `@supabase/ssr` [#with-supabasessr]

[`@supabase/ssr`](https://github.com/supabase/ssr) handles cookie sessions for Next.js, SvelteKit, Remix, React Router and Astro; it replaces the `auth-helpers-*` packages. The server client it creates is the one to call `getClaims()` on, exactly as the API section above shows, and the result feeds `subjectFromSupabase` inside the framework adapter's `subject` callback:

```ts
// app/permdock.ts (Next.js, permdock/next)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { createPermDock } from "permdock/next";
import { subjectFromSupabase } from "permdock/supabase";

export const { getPermDock, getPermission } = createPermDock(policy, {
  subject: async () => {
    const store = await cookies();
    const supabase = createServerClient(url, publishableKey, {
      cookies: { getAll: () => store.getAll(), setAll: () => {} },
    });
    const { data, error } = await supabase.auth.getClaims();
    return subjectFromSupabase(error ? null : data.claims, {
      roles: "user_role",
      tenant: "tenant_id",
    });
  },
});
```

Two rules carry over from the SSR package's own guidance. Use `getClaims()` (or `getUser()`), never `getSession()` alone: `getSession()` reads the cookie without contacting Auth or the JWKS, so its `access_token` is unverified material and must not become a subject. And keep the session refresh in the framework's middleware or `proxy` (the `updateSession` recipe from the SSR docs) separate from the PermDock factory: refreshing is authentication, upstream of PermDock ([authentication](/docs/concepts/authentication)). The browser client from `createBrowserClient` never builds a subject; the client `PermDock` comes from `permdock.snapshot()` through [`permdock/react`](/docs/adapters/react).

## Verified material [#verified-material]

The provider consumes claims that Supabase has already verified; it never parses a raw JWT itself ([Authentication and PermDock](/docs/concepts/authentication)).

### Signing keys and `getClaims()` [#signing-keys-and-getclaims]

Supabase Auth has two signing systems ([JWT signing keys](https://supabase.com/docs/guides/auth/signing-keys)):

| System | Algorithm | Verification | Status |
| --- | --- | --- | --- |
| JWT signing keys | Asymmetric RSA or EC; ES256 recommended | Locally, against the project JWKS at `https://<project>.supabase.co/auth/v1/.well-known/jwks.json` | Recommended |
| Legacy JWT secret | HS256 shared secret (also signs the `anon` and `service_role` keys) | Round trip to the Auth server | No longer recommended |

`supabase.auth.getClaims()` picks the right path: with asymmetric keys it verifies the signature locally against the JWKS (rotation follows standby, current, previously used, revoked, so a freshly rotated key is already in the set); with the legacy secret it calls the Auth server. Either way the output is verified, and it is the input to `subjectFromSupabase`. Prefer `getClaims()` over decoding the session's `access_token` yourself, and prefer asymmetric keys so a compromised server never holds a secret that can mint tokens. Apps that verify Supabase tokens outside the Supabase client (a Hono API without `@supabase/ssr`) can point [`permdock/jwt`](/docs/adapters/jwt) at the same JWKS URL with `algorithms: ['ES256']`, `issuer: 'https://<project>.supabase.co/auth/v1'` and `audience: 'authenticated'`.

### Claim mapping [#claim-mapping]

`subjectFromSupabase(claims, options)` maps the minted claims to the subject:

| Claim | Trust | Becomes |
| --- | --- | --- |
| `sub` | Verified | `principal.id` |
| `role` (`anon`, `authenticated`, `service_role`) | Verified | The Postgres role the request runs under; `anon` yields the anonymous subject; `service_role` is refused for subject building (a bypass role is not a principal) |
| `app_metadata.*` and hook-injected top-level claims (`user_role`, `tenant_id`, `memberships`) | Server-set, trusted | `principal.roles`, `principal.tenant`, `principal.memberships`, `principal.claims.*` |
| `user_metadata.*` | User-writable through the client SDK | **Never read.** A user can set `user_metadata.role = 'admin'` on themselves; it must not become a grant |
| `aal` (`aal1`, `aal2`) | Verified | `principal.assurance.acr` (Supabase's level takes the `acr` slot, [subject](/docs/concepts/subject)), for grants that require MFA (`where: { subject: { assurance: { acr: 'aal2' } } }`); a failed condition is `denied` with reason `insufficient-user-authentication` |
| `amr` (array of `{ method, timestamp }`) | Verified | Not copied into `principal.assurance.amr` (those objects are not RFC 8176 strings). Use `aal` for MFA, or map `method` yourself in a wrapper |
| `iss` | Verified | `principal.issuer`; the project's Auth URL |
| `session_id` | Verified | Carried on audit events; the key a CAEP `session-revoked` event invalidates |
| `exp` | Verified | `subject.expiresAt`, copied into snapshots |
| `email`, `phone`, `is_anonymous` | Verified | Exposed on `principal` only when `options.include` names them; not used for grants. With `anonymousSignIns: 'deny'`, `is_anonymous: true` yields the anonymous subject |
| The claim `options.plans` names (for example `features`), an object keyed by tenant id | Server-set, trusted | `principal.plans`, from the active tenant's entry only, so `plan()` grants apply; another tenant's plans are never read |
| `act` (RFC 8693, the outermost `sub` is the current actor) | Verified | `subject.actor` by the outer `kind`: none is `{ id, kind: 'oauth-client' }` with the chain on `delegation.chain`, `support` is `{ id, kind: 'support', sessionId, readOnly }`, `impersonation` is `{ id, kind: 'impersonation' }`. A malformed `act`, another `kind`, or `support` without `session_id` yields the anonymous subject and `on('auth')` cause `invalid-chain` |
| `client_id` (a token Supabase's OAuth server issued to a third-party app) | Verified | `subject.actor` `{ id: client_id, kind: 'oauth-client' }` when there is no `act` |
| `scope` | Verified | `delegation.scopes`, without the OpenID Connect identity scopes (`openid`, `profile`, `email`, `address`, `phone`, `offline_access`). An actor needs a delegation that covers the permission, so a client token without a matching scope is `denied` with `no-delegation` or `not-delegated` unless a policy delegation covers the client ([OAuth server tokens](#oauth-server-tokens)) |

### Actor and delegation: `actorOf` and `delegationOf` [#actor-and-delegation-actorof-and-delegationof]

The two rules in the last three rows are exported, so a session library derives the same actor PermDock does instead of re-implementing them:

```ts
import { actorOf, delegationOf } from "permdock/supabase";

const actor = actorOf(claims); // { ok: true, actor?: SupabaseActor } | { ok: false, reason: 'invalid-chain' }
if (!actor.ok) return deny(); // a malformed act chain is never a user acting alone
const delegation = delegationOf(claims); // { scopes } | undefined
```

* `actorOf` reads only `act` and `client_id`. `act` wins: the actor is its outermost `sub` (the current actor; nested levels are prior actors), and `chain` is a frozen copy of the claim. Every level must carry a non-empty string `sub`. Without `act`, a non-empty `client_id` is the actor. With neither, the result is `{ ok: true }` with no actor.

* The outer level's `kind` names the actor:

  | `act` | `SupabaseActor` |
  | --- | --- |
  | no `kind` (an OAuth client or an agent chain) | `{ kind: 'oauth-client', id, chain }` |
  | `{ kind: 'support', sub, session_id, read_only, reason }` | `{ kind: 'support', id, sessionId, readOnly, reason, chain }` |
  | `{ kind: 'impersonation', sub, reason }` | `{ kind: 'impersonation', id, reason, chain }` |

  A level with a `session_id` and no `kind`, the shape older support tools minted, reads as `support`. A support level without `read_only` is read-only.

* `{ ok: false, reason: 'invalid-chain' }` must deny. An `act` that is not a chain of objects ending in a non-empty `sub`, that names another `kind`, or a support level without a non-empty `session_id` or with a non-boolean `read_only`, is not proof of who acts, and treating the token as the user alone would drop the delegation limit. `actorOf` also returns it when reading the claims throws.

* `delegationOf` reads only `scope`: a space-separated string or a list of strings becomes `scopes`, without the OpenID Connect identity scopes, and an empty or malformed value, or one with only identity scopes, is `undefined`. `subjectFromSupabase` sets `delegation` only for an `oauth-client` actor, as `{ scopes, chain }`. A support or impersonation actor has none, so it reaches nothing until the policy delegates to it ([support and impersonation actors](#support-and-impersonation-actors)).

* Callers apply the role rule first. A token with `role` set to `anon` or `service_role` maps to the anonymous subject and never carries an actor or a delegation, whatever its `act`, `client_id` or `scope` say: `anon` is no user, and `service_role` is a bypass key, not a principal. Neither function looks at `role`, so a caller that skips this check would hand an actor to a token PermDock refuses.

Both take `unknown`, never throw, and return frozen values. For every `supabaseClaimFixtures` entry the test suite checks that they agree with `subjectFromSupabase(...).actor` and `.delegation`.

A `memberships` entry the reader cannot use (no `roles` array, or none of `scope` plus `id`, `tenant`, `team` or `on`) is dropped, never guessed at, and `options.onAuth` receives `{ reason: 'schema', cause: 'membership-dropped', source: 'supabase' }`. Access still fails closed; the event tells you the hook and the reader disagree. `permdock doctor` PD039 reports the same entries from the `doctor.claims` samples.

A top-level claim set to `null` counts as absent. The RBAC hook writes `user_role: null` for a user with no row in `user_roles`, so the reader falls back to `app_metadata.user_role` instead of letting the `null` shadow it. `null` under `app_metadata` is also absent: no roles.

### OAuth server tokens [#oauth-server-tokens]

Supabase's OAuth 2.1 server issues tokens with `client_id` and the OpenID Connect scopes the client asked for, such as `openid email profile`. Those scopes say which identity claims the client may read; they say nothing about what it may do in the app. `delegationOf` therefore leaves them out, and a token whose `scope` holds nothing else is an `oauth-client` actor with no delegation: every check is `denied` with `no-delegation`, as for any actor nothing delegated to.

To let a client act for its users, state it in the policy. A [policy delegation](/docs/security/delegation#policy-delegations) names the actor kind, optionally the client, the users who hand over and the permissions. Supabase assigns client ids per project (and at dynamic registration), so the policy names a stable client name and `subjectFromSupabase(claims, { clients })` maps the verified `client_id` to it:

```ts
export const policy = definePolicy(
  { permissions, roles },
  {
    roles: [...],
    delegations: [
      {
        from: authenticated(),
        to: { kind: "oauth-client", client: "cli" }, // a name, the same in every environment
        permissions: [permissions.customer.read, permissions.quote],
      },
    ],
    subject: (user: Principal | null) => user,
  },
);
```

```ts
const subject = subjectFromSupabase(claims, {
  clients: { cli: env.CLI_CLIENT_ID }, // name to id; an unset id names nothing
});
```

`clients` is a `ClientNames`: a record from name to client id (or a list of ids), or a function from a verified client id to a name, for clients registered at runtime that the app looks up before it maps the claims. The `oauth-client` actor gets `client: 'cli'` when exactly one name claims its id; an unknown id, an unset entry, an id two names claim, or a function that throws or returns no name leaves `client` unset, and the delegation does not apply. `to: { kind, id }` still matches one literal id; a target cannot name both.

The user's own grants still decide inside that ceiling. A token that names permission scopes as well (`openid quote:read`) narrows the ceiling further, because token and policy delegations intersect. A client the policy does not name keeps reaching nothing.

### Session objects: `subjectFromSupabaseSession` [#session-objects-subjectfromsupabasesession]

`subjectFromSupabaseSession(session, options)` takes any verified session object shaped `{ kind, claims }` (`SupabaseSessionLike`), such as the session a session library builds from verified claims. It maps `kind: 'user'` through `subjectFromSupabase(session.claims, options)` and returns the anonymous subject for every other `kind` (`anon`, `service`, `invalid`, or anything unknown). PermDock imports nothing from the library that produced the session; the shape is the contract.

```ts
const session = await getSession(); // verified locally against the project JWKS
const subject = subjectFromSupabaseSession(session, { roles: "user_role" });
```

### Anonymous sign-ins [#anonymous-sign-ins]

Supabase gives a [`signInAnonymously()`](https://supabase.com/docs/guides/auth/auth-anonymous) user the `authenticated` role and `is_anonymous: true`, so by default `subjectFromSupabase` maps them as a user like any other. `anonymousSignIns: 'deny'` maps them to the anonymous subject instead, so only `anyone()` grants apply. Set it together with `rls.anonymousSignIns: 'deny'`, which does the same in the generated policies; `permdock doctor` PD057 names a subject call without it:

```ts
const subject = subjectFromSupabaseSession(session, {
  anonymousSignIns: "deny",
});
```

### Live sessions [#live-sessions]

A Supabase access token stays valid until `exp` after sign-out or revocation. A grant with `{ subject: { session: { live: true } } }` holds only while the session is still in `auth.sessions` ([live sessions](/docs/concepts/conditions#live-sessions)). In process, pass `liveSession: true` once this request checked the session against the Auth server:

```ts
const { data, error } = await supabase.auth.getUser();
const subject = subjectFromSupabase(claims, {
  liveSession: error === null && data.user !== null,
});
```

The option marks only a token with a `session_id`, and a `liveSession` claim is ignored. In RLS, `rls generate` emits `permdock.permdock_session_live()` when a grant reads the session, and the policy calls it on every query.

### Claim size and membership mode [#claim-size-and-membership-mode]

`permdock supabase hook generate` builds the whole hook from the app's membership sources, with a byte budget, `memberships_truncated`, `authz_ver` for `fresh` permissions and IdP-row protection ([Supabase token hook](/docs/adapters/supabase-hook)). It writes these claims, and `permdock supabase inspect` lists them for a given config:

| Claim | Source | Counts toward the budget |
| --- | --- | --- |
| `user_role`, `roles` | `supabase.hook.roles` (default `rls.roles`, else `<schema>.user_roles`; `role: { through, on, column }` reads keys through a roles table); `[]` for a suspended user | No |
| `memberships` | `supabase.hook.memberships`, the active first-scope instance first; a source's role may be read through a roles table the same way, and its user through a profile table | Yes |
| `memberships_truncated` | Set to `true` when memberships or `attrs` were cut to fit | No |
| `tenant_id` (`rls.tenantClaim`) | `supabase.hook.activeFrom`, when the user holds a membership there | No |
| `attrs` | `supabase.hook.attrs`, when configured | Yes |
| `authz_ver` | `permdock_authz_version`, unless `version: false` | No |
| each `supabase.hook.claims` key | Its `<schema>.<function>(uuid)`; left out for a suspended user | No |

The helpers the claims feed come from `permdock rls generate`, so a new project runs both commands: `permdock rls generate --target sql --out supabase/migrations/<n>_permdock_rls.sql` and then `permdock supabase hook generate`. `hook generate` warns with PD039 while the helper schema has no `permdock_has`, `permitted_<scope>_ids` or `member_<scope>_ids`, reading `rls.out` (its `helpers` part when it has `{part}`), the folder of the hook file and the migration folders, or the database with `--db`.

Claims travel in the session cookie and on every request. Keep the hook's `memberships` claim under about 1 KB of JSON: about 15 UUID-keyed, single-role memberships. `permdock/testing` exports this ceiling as `supabaseMembershipsBudget`, along with `supabaseClaimFixtures` in the hook's shape. Above the ceiling, or when a role change must apply before the next token refresh, switch to database mode. The hook writes no memberships, the helpers and `authorize()` read the membership table, and the app passes rows from its own query to `snapshotFor(policy, claims, { memberships })`. JWT mode costs a token refresh of staleness (`jwt_expiry`, 3600 s by default); database mode costs a query per snapshot.

### The Custom Access Token Hook [#the-custom-access-token-hook]

The hook runs before Supabase Auth issues a token and may add or remove claims ([Custom Access Token Hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook)). It is how `user_role` and `memberships` get into the JWT so that both the RLS helpers in `jwt` mode and `subjectFromSupabase` in the app read the same claims from the same place. `permdock supabase hook generate` writes it from the app's `fromTable` / `fromJunction` sources, with the grants `supabase_auth_admin` needs; the [Supabase token hook](/docs/adapters/supabase-hook) page shows the SQL and the claims. The hook is enabled in the dashboard under Authentication, then Hooks, or in `config.toml` for local development.

HTTP form: an endpoint (typically an Edge Function) that receives a JSON body with `user_id`, `claims` and `authentication_method`, signed with a standard-webhooks secret (`v1,whsec_...`) that the function must verify before trusting the body, and returns a JSON object with the modified `claims`. Use it when roles live outside Postgres (an external HR system, a billing provider). The SQL hook is what `permdock supabase hook generate` emits; an HTTP hook is yours to host.

Either form can also strip claims to shrink the token. The generated hook does not strip anything. If you strip claims, keep `iss`, `aud`, `exp`, `iat`, `sub`, `role`, `aal`, `session_id`, `email`, `phone` and `is_anonymous`, the set Supabase clients and `subjectFromSupabase` need.

The two `--authorize` modes trade freshness for a query:

| Mode | The helpers and `authorize()` read | A role change reaches RLS | A role change reaches `principal.roles` |
| --- | --- | --- | --- |
| `database` (default) | `user_roles` and the membership table | On the next statement | On the next token refresh, unless the app loads memberships itself (`snapshotFor(policy, claims, { memberships })`) |
| `jwt` | The hook-injected claims | On the next token refresh | On the next token refresh |

A suspension in `rls.suspension` reaches RLS on the next statement in both modes, because the helpers read the status tables directly. It reaches `principal.memberships` on the next token refresh, unless the app reads memberships through a `MembershipSource` that honours the same status.

In `jwt` mode the policy and PermDock read one claim written by one function. Keep `jwt_expiry` at 3600 seconds or less when the policy grants sensitive verbs; `permdock doctor` flags it as PD019. A [Shared Signals receiver](/docs/adapters/ssf) can reject a revoked session for the app, not for RLS. `updateTag` and `router.refresh()` do not help in `jwt` mode: they re-render from the same stale claims. The per-layer staleness table for a Next.js app, including other members' browsers, is in the [Next.js Cache Components guide](/docs/guides/next-cache-components).

## Memberships in an app schema [#memberships-in-an-app-schema]

A membership source may name a schema-qualified table. The hook and the in-process source quote `"app"."memberships"`, and the hook grants `supabase_auth_admin` usage on the schema and `select` on the table:

```ts title="permdock.config.ts"
import { fromJunction } from "permdock/supabase";

export default {
  supabase: {
    hook: {
      memberships: [
        fromJunction({
          table: "app.memberships",
          scope: "organization",
          id: "organization_id",
          roles: "role",
        }),
      ],
    },
  },
};
```

Pass the same source, with a `query` over the app's connection, as `memberships` to `createPermDock` so `decide` reads the rows the token was minted from. `tests/integration/src/supabase-junction.test.ts` runs the round trip against a schema-qualified table in Postgres. Claims another package owns, such as plan features, go through `supabase.hook.claims` ([claims other packages own](/docs/adapters/supabase-hook#claims-other-packages-own)).

An app on better-supabase uses the same claims, hook and helpers, and adds `permdock/better-supabase` for its config, bucket, topic and API key slots ([better-supabase](/docs/adapters/better-supabase)).

## Request lifecycle [#request-lifecycle]

1. The client signs in; Supabase Auth runs the `custom_access_token_hook` from `permdock supabase hook generate`, which reads `user_roles` and the membership sources and adds `user_role`, `memberships` and the tenant claim to the JWT.
2. Server request: the adapter in use (`permdock/next` over `@supabase/ssr`, `permdock/hono` over `@supabase/server`'s Hono adapter, or `withPermDock` after `withClaims` in a pipeline) takes the claims Supabase verified, calls `subjectFromSupabase`, and creates the request-scoped `PermDock`.
3. In-process checks (`can`, `assert`, `filter`, `where`) run with `principal.roles` and `principal.tenant` from the claims, `principal.memberships` from the claim or the membership source, and `subject.claims` for other conditions.
4. Database access: with `toWhere` the filter travels in the query; with RLS the database evaluates the helpers (once per statement) and the compiled conditions under the `authenticated` role with `request.jwt.claims` set by PostgREST or by the app's transaction preamble.
5. Snapshot: `permdock.snapshot()` is sent to the client; the browser never derives permissions from the JWT roles, even offline. Claims are stale until token refresh, so a role change should also trigger `updateTag` and a session refresh.

## Sessions and devices [#sessions-and-devices]

A Supabase token names who acts for the user in `act`, and Supabase Auth lists and revokes the sessions behind it.

### Support and impersonation actors [#support-and-impersonation-actors]

A support tool that mints a token for the user with the admin in `act` (`act.kind: 'support'` or `'impersonation'`) gives a subject where `subject.principal` is the user and `subject.actor` is the admin, kind `support` or `impersonation`. Neither carries a token delegation, so every call denies with `no-delegation` until the policy names the actor kind in a [delegation](/docs/security/delegation#policy-delegations):

```ts
definePolicy(permissions, {
  roles,
  delegations: [
    { from: "member", to: actor("support"), permissions: [permissions.ticket] },
  ],
});
```

A support session with `read_only: true` reaches only the delegated permissions whose `readOnlyHint` is true (`meta.readOnly`, else a `read` or `list` action); any other permission denies with `not-delegated`, on the server and in the snapshot alike. The principal stays the user, so this is not PermDock's [`supportAccess`](/docs/concepts/elevated-access#support-access-with-tenant-consent), where the vendor is the principal; a support actor does satisfy `actorRequired` there.

List and revoke sessions through the Supabase Auth API (`auth.admin.listUserSessions`, `auth.admin.signOut` / revoke). PermDock does not own a device list UI. Revocation reaches PermDock when the project emits Back-Channel Logout or a CAEP `session-revoked` SET into [`permdock/ssf`](/docs/adapters/ssf), joined on `subject.session` (`session_id`). With `approvals` set on the SSF factory, pending approval requests for that session are cancelled.

## What it validates [#what-it-validates]

* Token verification is Supabase's job (`auth.getUser()` or JWKS); the provider never parses an unverified JWT for authorization.
* Role claim shape: `user_role` is a string or string array; anything else yields no roles. A role name the policy does not declare grants nothing (denial reason `unknown-role`), and `declared` drops such names at the boundary. Either way the result is fail closed: fewer grants, never more.
* `app_metadata` versus `user_metadata`: only `app_metadata` claims are exposed as `subject.claims`.
* `permdock rls generate --rbac supabase` derives the enums and `role_permissions` seed rows from the policy at migration time, so they cannot disagree with it; `--check` fails when the file on disk has drifted. The membership table stays opt-in (`--memberships`). Suspension is checked live in both modes, so a disabled organization loses RLS access on the next statement even while its members' tokens still name it. On import, the deprecated `auth.role()` is not mapped and stays `opaque`. `permdock rls verify` runs fixtures with `set local role authenticated` and `request.jwt.claims` containing `user_role`.

## How denials surface [#how-denials-surface]

* In-process: the shared contract on [adapters](/docs/adapters).
* In Postgres: a helper that admits no role yields `filtered` (SELECT, UPDATE `USING`) or `rejected 42501` (INSERT and UPDATE `WITH CHECK`); `permdock rls verify` maps these to the in-process outcome.
* Anonymous requests: `auth.uid()` is NULL, so `(select auth.uid()) = user_id` is never true and every ownership policy filters; in-process, `subjectFromSupabase(null)` yields the anonymous subject and only anonymous grants apply.
* Stale claims: a demoted user keeps `user_role` until refresh; the [SSF receiver](/docs/adapters/ssf) or an app-level `updateTag` on role change shortens the window.

## Example app [#example-app]

`apps/examples/supabase-rls`: a Hono server on `127.0.0.1:3469`. `GET /health`, `GET /rls/authorize` checks that generated SQL never contains `service_role`, and `PATCH /posts/:id` uses `subjectFromSupabase` with fixed JWT-shaped claims. No live Supabase project.

`apps/examples/supabase-middleware`: a `@supabase/middleware` pipeline on `127.0.0.1:3477`. `withClaims` from `@supabase/server` verifies a real ES256 bearer token against an in-process JWKS, `withPermDock()` contributes `ctx.permdock` for `PATCH /posts/:id`, `withPermDock({ protect })` guards `POST /posts/:id/publish` with Problem Details, and `POST /api/permdock` mounts `permdockHandler()`. `GET /posts/review-queue` gates on `post.review` with `withFeatureFlag`, and `GET /flags` resolves OpenFeature flags targeted at the resolved subject ([feature flags](#feature-flags)). `GET /dev/token/member` and `GET /dev/token/admin` mint development tokens; a deployment points `withClaims` at the project JWKS instead. No live Supabase project.

`apps/examples/next-better-supabase` runs the same claims and helpers under better-supabase ([better-supabase](/docs/adapters/better-supabase#example-app)).

## Related standards [#related-standards]

* [Postgres RLS](/docs/standards/postgres-rls): `auth.uid()`, `auth.jwt()`, `authorize()`, roles `anon` / `authenticated` / `service_role`, the RBAC guide, Splinter lints and `supabase gen types` limits.
* [RLS adapter](/docs/adapters/rls): `generate`, `import`, `verify`.
* [Subject](/docs/concepts/subject): principal fields and `subject.context`.
* [Tenants, teams and scoped roles](/docs/concepts/tenancy): memberships, the active tenant, `memberOf` compilation.
* [MCP adapter](/docs/adapters/mcp): `withOAuthProtectedResource` with Supabase Auth as the OAuth 2.1 authorization server.
