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 fromgetClaims()(ornull) and returns a subject whose principal is{ id: sub, roles, tenant, memberships, assurance, claims }:idis thesub;rolesis derived from the configured role claim (defaultuser_role, top-level as the hook writes it or underapp_metadata) and holds global roles;tenantcomes from the configured tenant claim (defaulttenant_id, hook-injected or underapp_metadata);membershipscomes from a hook-injected claim of{ tenant, team?, roles }entries when the hook writes one, or from aMembershipSourceover your tables when it does not;assuranceisaal;claimsexposesapp_metadatavalues.user_metadatais never read because it is user-writable. It never throws; malformed oranonclaims 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 reachprincipal.claims; an invalid claim set dropsclaimswith a development warning, never the subject.supabaseRls(options)tellspermdock rlshowsubject.id((select auth.uid())),subject.claim(...)((select auth.jwt()) ->> 'claim'),subject.roles(authorize()) andmemberOfnodes (a claim comparison for the active tenant, a membership-tableexistsotherwise) compile (tenancy, portable compilation).permdock rls generate --rbac supabaseemits: enumsapp_roleandapp_permission(fromlistPermissions), tablesuser_roles(user_id, role)androle_permissions(role, permission), seed rows for every role and grant in the policy, thecustom_access_token_hook(event jsonb)PL/pgSQL function, and thesecurity definer stablefunctionauthorize(requested_permission app_permission) returns boolean.- Named permissions without conditions compile to
using ((select authorize('post.delete'))); conditional grants compile toauthorize(...) and (cond).
Claim mapping used by both the in-process subject and the RLS compiler:
| Subject field | Source in the JWT | In generated policies |
|---|---|---|
subject.id | sub | (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 claim | org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid for tenant-scoped roles |
principal.memberships | A hook-injected memberships claim, or a MembershipSource over your tables | exists (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' |
| anonymous | no token, or role = anon | auth.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):
| 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 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:
| 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, for grants that require MFA (where: { subject: { assurance: 'aal2' } }) |
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 |
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
- The client signs in; Supabase Auth runs the
custom_access_token_hook, which readsuser_rolesand addsuser_roleto the JWT. - Server request: the adapter in use (
permdock/next,permdock/hono) verifies the token through the Supabase server client, callssubjectFromSupabase, and creates the request-scopedPermDock. - In-process checks (
can,assert,filter,where) run withprincipal.rolesandprincipal.tenantfrom the claims,principal.membershipsfrom the claim or the membership source, andsubject.claimsfor other conditions. - Database access: with
toWherethe filter travels in the query; with RLS the database evaluatesauthorize()and the compiled conditions under theauthenticatedrole withrequest.jwt.claimsset by PostgREST or by the app's transaction preamble. - Snapshot:
permdock.snapshot()is sent to the client; claims are stale until token refresh, so a role change should also triggerupdateTagand 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_rolemust be a string or string array matching role names declared indefinePolicy; unknown role names are dropped with a warning (fail closed: fewer grants, never more). app_metadataversususer_metadata: onlyapp_metadataclaims are exposed assubject.claims.permdock rls generate --rbac supabasechecks that every grant's permission key fits theapp_permissionenum and that seed rows match the policy;permdock rls verifyruns fixtures withset local role authenticatedandrequest.jwt.claimscontaininguser_role.
How denials surface
- In-process: the usual Decision; HTTP adapters produce RFC 9457
403bodies. - In Postgres:
authorize()returning false yieldsfiltered(SELECT, UPDATEUSING) orrejected 42501(INSERT and UPDATEWITH CHECK);permdock rls verifymaps these to the in-process outcome. - Anonymous requests:
auth.uid()is NULL, so(select auth.uid()) = user_idis 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_roleuntil refresh; the SSF receiver or an app-levelupdateTagon 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.
Related standards
- Postgres RLS:
auth.uid(),auth.jwt(),authorize(), rolesanon/authenticated/service_role. - Research: Postgres RLS: Supabase specifics, RBAC guide, Splinter lints,
supabase gen typeslimits. - RLS adapter:
generate,import,verify. - Subject: principal fields and
subject.context. - Tenants, teams and scoped roles: memberships, the active tenant,
memberOfcompilation.
Open questions
- The plan names the provider's job (JWT claims to subject,
authorize()scaffold, compile toauthorize('perm'));subjectFromSupabaseandsupabaseRlsare proposed names for the two exports and may change. - Whether
role_permissionsshould be seeded from the policy at migration time (drift risk when roles change in code) or read back at runtime throughimport. - Multiple roles per user and per organization: resolved by ADR 0024.
user_rolemay be a string or an array and holds global roles; per-organization roles travel in a hook-injectedmembershipsclaim or are read from a membership table, and the generatedauthorize()gains an optionaltenant_id uuidparameter 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
membershipsclaim for a user in many organizations grows the JWT; when to prefer theMembershipSource(a query per request) over the claim is a recipe, not a rule, andpermdock doctorwarns above a configurable claim size. - Whether to support the deprecated
auth.role()on import for older projects or map it to theTOclause 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.
Postgres RLS
permdock rls generate, import and verify round-trip PermDock policies and Postgres row-level security across Drizzle, raw SQL and Prisma 8 targets for Supabase, Neon and generic Postgres.
Better Auth
The permdock/better-auth provider builds a PermDock subject from Better Auth sessions and organization roles, including dynamic database roles, so PermDock's conditions, snapshots and adapters layer on top of Better Auth access control.