PermDock
Adapters

Supabase

The permdock/supabase provider maps Supabase JWT claims to a PermDock subject, scaffolds the authorize() RBAC tables and hook, and compiles named permissions to authorize('perm') calls in RLS policies.

Status: planned Phase: 3

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: the user_roles / role_permissions / authorize() scaffold from Supabase's RBAC guide and the compilation of named permissions to authorize('perm').

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) 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 generated policies call authorize() with PermDock keys.

API

import { createPermDock } from 'permdock'
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 [{ tenant, team?, roles }] from your tables, optional
}))

// policy: tenant roles are scoped, RLS reads the same claims
export const policy = definePolicy(permissions, {
  roles: [member, admin],
  scopes: { tenant: { key: 'orgId' }, team: { key: 'teamId' } },
  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 tenant_id, hook-injected or under app_metadata); memberships comes from a hook-injected claim of { tenant, team?, roles } entries when the hook writes one, or from a MembershipSource over your tables when it does not; assurance 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.
  • supabaseRls(options) tells permdock rls how subject.id ((select auth.uid())), subject.claim(...) ((select auth.jwt()) ->> 'claim'), subject.roles (authorize()) and memberOf nodes (a claim comparison for the active tenant, a membership-table exists otherwise) compile (tenancy, portable compilation).
  • permdock rls generate --rbac supabase emits: enums app_role and app_permission (from listPermissions), tables user_roles(user_id, role) and role_permissions(role, permission), seed rows for every role and grant in the policy, the custom_access_token_hook(event jsonb) PL/pgSQL function, and the security definer stable function authorize(requested_permission app_permission) returns boolean.
  • Named permissions without conditions compile to using ((select authorize('post.delete'))); conditional grants compile to authorize(...) and (cond).

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

Subject fieldSource in the JWTIn generated policies
subject.idsub(select auth.uid())
subject.roles (global)user_role (from the Auth Hook)authorize('perm') via role_permissions
principal.tenant (active tenant)tenant_id: app_metadata.tenant_id or a top-level hook-injected claimorg_id = ((select auth.jwt()) ->> 'tenant_id')::uuid for tenant-scoped roles
principal.membershipsA hook-injected memberships claim, or a MembershipSource over your tablesexists (select 1 from organization_members m where m.organization_id = org_id and m.user_id = (select auth.uid()) and m.role = any(...)) from supabaseRls({ memberships })
subject.claim('plan')app_metadata.plan or a top-level custom claim(select auth.jwt()) ->> 'plan'
anonymousno token, or role = anonauth.uid() is NULL; TO anon grants only

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).

Verified material

The provider consumes claims that Supabase has already verified; it never parses a raw JWT itself (Authentication and PermDock).

Signing keys and getClaims()

Supabase Auth has two signing systems (JWT signing keys):

SystemAlgorithmVerificationStatus
JWT signing keysAsymmetric RSA or EC; ES256 recommendedLocally, against the project JWKS at https://<project>.supabase.co/auth/v1/.well-known/jwks.jsonRecommended
Legacy JWT secretHS256 shared secret (also signs the anon and service_role keys)Round trip to the Auth serverNo 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 at the same JWKS URL with algorithms: ['ES256'], issuer: 'https://<project>.supabase.co/auth/v1' and audience: 'authenticated'.

Claim mapping

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

ClaimTrustBecomes
subVerifiedprincipal.id
role (anon, authenticated, service_role)VerifiedThe 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, trustedprincipal.roles, principal.tenant, principal.memberships, principal.claims.*
user_metadata.*User-writable through the client SDKNever read. A user can set user_metadata.role = 'admin' on themselves; it must not become a grant
aal (aal1, aal2)Verifiedprincipal.assurance, for grants that require MFA (where: { subject: { assurance: 'aal2' } })
session_idVerifiedCarried on audit events; the key a CAEP session-revoked event invalidates
expVerifiedsubject.expiresAt, copied into snapshots
email, phone, is_anonymousVerifiedExposed on principal only when options.include names them; not used for grants

The Custom Access Token Hook

The hook runs before Supabase Auth issues a token and may add or remove claims (Custom Access Token Hook). It is how user_role gets into the JWT so that both authorize() in RLS and subjectFromSupabase in the app read the same role from the same place.

SQL form, as permdock rls generate --rbac supabase emits it:

create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
  claims jsonb;
  user_role public.app_role;
begin
  select role into user_role from public.user_roles where user_id = (event ->> 'user_id')::uuid;

  claims := event -> 'claims';
  if user_role is not null then
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  else
    claims := jsonb_set(claims, '{user_role}', 'null');
  end if;

  event := jsonb_set(event, '{claims}', claims);
  return event;
end;
$$;

grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;

grant all on table public.user_roles to supabase_auth_admin;
revoke all on table public.user_roles from authenticated, anon, public;
create policy "Allow auth admin to read user roles" on public.user_roles
  as permissive for select to supabase_auth_admin using (true);

The grants matter as much as the function: the hook runs as supabase_auth_admin, which needs usage on the schema, execute on the function and read access to user_roles; everyone else must lose execute so a client cannot call the hook through PostgREST. 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). permdock rls generate --rbac supabase --hook http emits the Edge Function skeleton with the signature check and the same user_role lookup against a configurable source.

Either form can also strip claims to shrink the token; the scaffold keeps iss, aud, exp, iat, sub, role, aal, session_id, email, phone and is_anonymous, which is the set Supabase clients and subjectFromSupabase need.

Because the scaffold emits the hook next to authorize(), the Postgres policy auth.jwt() ->> 'user_role' and PermDock's principal.roles are two readers of one claim written by one function. A role change in user_roles reaches both on the next token refresh; a Shared Signals receiver or an app-level updateTag shortens the window.

Request lifecycle

  1. The client signs in; Supabase Auth runs the custom_access_token_hook, which reads user_roles and adds user_role to the JWT.
  2. Server request: the adapter in use (permdock/next, permdock/hono) verifies the token through the Supabase server client, 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 authorize() 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; claims are stale until token refresh, so a role change should also trigger updateTag and a session refresh.

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 must be a string or string array matching role names declared in definePolicy; unknown role names are dropped with a warning (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 checks that every grant's permission key fits the app_permission enum and that seed rows match the policy; permdock rls verify runs fixtures with set local role authenticated and request.jwt.claims containing user_role.

How denials surface

  • In-process: the usual Decision; HTTP adapters produce RFC 9457 403 bodies.
  • In Postgres: authorize() returning false 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 or an app-level updateTag on role change shortens the window.

Example app

apps/examples/supabase-rls: shared with the RLS adapter: a Next.js app using @supabase/ssr, subjectFromSupabase in src/permdock/server.ts, the generated RBAC scaffold as a Supabase migration, authorize()-based policies, and supabase test db pgTAP files produced by permdock rls verify --format pgtap.

Open questions

  • The plan names the provider's job (JWT claims to subject, authorize() scaffold, compile to authorize('perm')); subjectFromSupabase and supabaseRls are proposed names for the two exports and may change.
  • Whether role_permissions should be seeded from the policy at migration time (drift risk when roles change in code) or read back at runtime through import.
  • Multiple roles per user and per organization: resolved by ADR 0024. user_role may be a string or an array and holds global roles; per-organization roles travel in a hook-injected memberships claim or are read from a membership table, and the generated authorize() gains an optional tenant_id uuid parameter that checks the membership table when it is configured. Whether the scaffold should emit that membership table by default is still open.
  • Token size: a memberships claim for a user in many organizations grows the JWT; when to prefer the MembershipSource (a query per request) over the claim is a recipe, not a rule, and permdock doctor warns above a configurable claim size.
  • Whether to support the deprecated auth.role() on import for older projects or map it to the TO clause only.
  • Client-side: whether the browser Supabase client should receive a PermDock snapshot from the server or derive a coarse one from the JWT roles when offline.

On this page