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: 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
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 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).
API
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 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 (defaultsupabaseTenantClaim,tenant_id, hook-injected or underapp_metadata; keep it equal torls.tenantClaim, whichpermdock doctorPD038 checks);membershipscomes from a hook-injected claim of{ scope, id, within?, roles, via?, expiresAt? }entries (the named scopes form;{ tenant, team? }is still read) when the hook writes one, or from aMembershipSourceover your tables when it does not;issuerisiss;assurance.acrisaal;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.postgrestSources(client, { schema?, fn?, membersFn?, versionFn?, policy? })reads a stored user's roles, memberships, held custom roles and authorization version through supabase-js from thesubject_for(p_user)function the hook file defines, for a backend with noSqlQuery:membershipsforclaimsFirst(withversion, oneauthz_version_for(p_user)call, so a fresh token costs no full read, andlist, the members of one instance frommembers_of(p_scope, p_id), forcountHoldersandwhoCan),customRoles(principal)andsubject(userId); withpolicy, custom roles are read only for a subject that holds one, and a token that claims one has its subject read in onesubject_forcall (a stored user over PostgREST).supabaseApprovalStore(client, { schema?, ttl?, onOpen? })is anApprovalStoreover the table and functionsrls.approvalsgenerates (a generated store).- Both take the client as a
SupabaseRpcCaller: anything withschema(name).rpc(fn, args), including a supabase-js client typed with a generatedDatabase(SupabaseClient<Database>), whoserpcaccepts only the functions thatDatabasedeclares, 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 asorg:<id>:chator an object under<id>/follows the same permission as your tables (Realtime and Storage).supabaseClaims({ tenantClaim? })is the claim contract as a Standard Schema v1 object, typedSupabaseClaims<TenantClaim>withSupabaseMembershipClaimentries. It needs no validation library, andtenantClaimdefaults totenant_id. See "Claims schema" below.
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:
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 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 therlsvalue inpermdock.config.ts. It tellspermdock rlshowprincipal.id((select permdock.permdock_user_id()), which answers asauth.uid()does and reads an emptysubas no user),principal.claim.<name>((select auth.jwt()) ->> 'claim'),principal.roles(thepermdock_hashelper) and tenant-scoped roles (thepermitted_tenant_idshelper, over the membership table or themembershipsclaim) compile;tenantTypesets the tenant column type the claim is cast to (defaultuuid) (tenancy, portable compilation).membershipsis one table, or{ scopes, tenant, team, resource }with a table per named scope.suspensionnames the users and scope-instance tables whosedisabledAtorstatuscolumn suspends a row (RLS).exchangeCapability(subject, { key, alg?, kid?, issuer?, ttl? })exchanges a verified link subject (fromsubjectFromCapability) for a short-lived Supabase access token:role: 'anon', the capability under acapabilityclaim,iat,exp(thettl, default 300 seconds and at most 3600, never past the capability's expiry) and nosub, becauseauth.uid()castssubto a uuid.keyis a private JWK imported into the project's JWT signing keys withalg: 'ES256'(the default) or'RS256'and itskid, or{ secret }withalg: 'HS256'named for a project on the legacy JWT secret. It returnsundefinedfor any subject that is not a live link, and never mintsservice_role.permdock rls generate --capabilitiesemits the policies that read the claim (link capabilities).authorizeSql({ schema, authorize, tenant })is theauthorize(requested_permission, requested_tenant text default null)function SQL.authorize: 'database'(the default) readsuser_roles, and for a tenant request the membership table passed astenant, whoseroleis a key column or{ through, on, column }for a role id read through a roles table.authorize: 'jwt'reads the hook-injecteduser_role(falling back toapp_metadata.user_rolewhen the top-level claim isnull) andmembershipsclaims. A tenant request with no memberships source returnsfalse; it is never answered from global roles.permdock rls generate --rbac supabaseemits the same function. It readsrole_permissionsbypermission, keeps onlyeffect = 'allow'rows of the matching scope, and ignores row conditions: it is for hand-written policies and RPCs. WithcustomRoles: { declared }(whatgenerate --rbac supabase --custom-rolespasses), a tenant request also answers from the membership's custom roles: thecustom_role_*tables indatabasemode, thememberships[].grantsclaim injwtmode, both through thepermdock_custom_keysfunction and the ceiling view the same command generates.declaredrole names never resolve as custom. Withsuspension, a suspended user, or a tenant request for a suspended instance of the first scope, answersfalsein both modes.permdock rls generate --rbac supabase [--rbac-schema permdock] [--authorize database|jwt] [--memberships <table>:tenant,user,role](--rbac-scaffoldis the older spelling) emits:- re-runnable enums
app_roleandapp_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-leveldefinePolicy({ grants })alike (everyrls generateemits these, with or without--rbac); - the
permdock_hashelper and onepermitted_<scope>_idsand onemember_<scope>_idsper declared scope,security definerwithsearch_path = '', executable byauthenticatedonly; - one
member_<scope>_ids_for(p_user uuid)per scope with a membership source, forsupabase.hook.claimsfunctions, executable by no client role (claims other packages own); authorize()as above, executable byauthenticatedonly; Everything lands in the chosen schema. It emits no token hook:permdock supabase hook generateis the one generator ofcustom_access_token_hook, so one function writesuser_role,membershipsand the other claims (Supabase token hook). The hook reads the sameuser_rolestable by default.
- re-runnable enums
- 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.insertpolicies carry onlywith check. No policy callsauthorize()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
Supabase's server packages compose through @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.
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 ofInstanceOptions) and returnswithPermDock,permdockHandlerandopenapi.subject(ctx, request)receives the pipeline context; the documented body issubjectFromSupabase(ctx.jwtClaims, …).tenantmay be a string or(ctx, request) => string | undefined; a requested tenant without a matching membership yields no tenant (tenancy).context(ctx, request),onDeniedandwrapbehave as on the server kernel.withPermDock()declaresjwtClaimsas its prerequisite and contributesctx.permdock, a frozen request-scopedPermDock. A pipeline that places it without an upstream contributor ofjwtClaims(withClaims,withRequiredClaims, orwithSupabase's context) is a type error, not a runtime surprise.withPermDock({ protect, data, trusted, oauthScopes })runs the kernel'sprotectbefore the handler (data's result is validated unlesstrusted: truemarks it as a row the server loaded):data(ctx, request)loads the row (anullis a404), a denial is a403Problem Details body withpermission,denialsandalternatives, anapproval-requiredoutcome is a403carryingapprovaland thePermDock-Approvalresume header is honoured (approvals). A pass contributesctx.permdockas above.oauthScopesnarrows which coarse OAuth scopes reach the handler: a delegated token holding none of them gets a403withWWW-Authenticate: Bearer error="insufficient_scope". Withoutprotect,withPermDock({ oauthScopes })checks no permission: an anonymous caller gets a401, and a signed-in one passes when it is not a delegated token or holds one of the scopes (server kernel).permdockHandler()returns a terminal handler forPOSTAuthZEN evaluations against the caller's own subject; the body'ssubjectnever overrides the verified claims.openapiis the same hook set the HTTP adapters expose forpermdock openapi.- A pipeline entry returns a contribution or a
Response; it cannot wrap the handler. Actx.permdock.assert(...)in the handler therefore throws out of the pipeline: catch it in the app'sfetchand returnproblemFromError(error)frompermdock/server, or usedecideand answer yourself. ctx.jwtClaimsis typed structurally asSupabaseJwtClaims(sub,role,app_metadata, index signature), so@supabase/server'sJWTClaimssatisfies it without a peer dependency, and so does any other entry that contributes the same key. Anull(no token,publishableorsecretmode) reachessubjectFromSupabaseand becomes the anonymous subject; an invalid token never reaches PermDock becausewithClaimsanswers401first.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).
With @supabase/server
@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 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). For MCP servers behind Supabase Auth as the OAuth 2.1 authorization server, see withOAuthProtectedResource on the MCP page.
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):
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
servicecredential resolves to:{ id, kind: 'service', tenant }with one{ tenant, roles, via: 'credential' }membership, anddelegationscopes forpermissions, so it can do at most what both its roles and its permissions allow.principal.credential.nameis the key name. - Only
authMode: 'secret'with anauthKeyNamethe map names, and nojwtClaims, takes this path. Every other key, every publishable key and a request that also carries a user token go throughsubjectas before. A name that is not a key name (*) or an entry withoutidortenantthrows whencreatePermDockruns. - In Postgres,
withPostgresClientruns a key-only request asanon, so query throughwithSubjectfrompermdock/drizzle,permdock/kyselyorpermdock/prismaon the app's own pool instead. For a credential principalwithSubjectwrites therls.apiKeysclaim,{ "sub": "", "api_key": { "id", "tenant", "roles", "scopes" } }with roleauthenticated, so the generated helpers hold the key to its tenant, roles and permissions. A permission entry limited toidshas no claim form and is left out ofscopes. Never usectx.supabaseAdminorwithPostgresAdminClientfor these requests: they bypass RLS.
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 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
@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:
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/frameworksin@supabase/server(hono,h3,elysia,nestjs,tanstack-start). It seeds the context withseedContext(c.env), buffers the body withbufferRequest, 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.tsis the Hono bridge with this repository's lint rules applied. - Chain
.use()into the routes: Hono typesc.varonly through the chained calls. Routes that need another entry list go in a sub-app mounted withapp.route(). permdock/honostays the choice for a Hono app that verifies tokens withpermdock/jwtinstead of@supabase/server. Both read the same claims; do not stack them on one route.
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:
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:
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
@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:
// 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). The browser client from createBrowserClient never builds a subject; the client PermDock comes from permdock.snapshot() through permdock/react.
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.acr (Supabase's level takes the acr slot, 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) |
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:
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-
actorOfreads onlyactandclient_id.actwins: the actor is its outermostsub(the current actor; nested levels are prior actors), andchainis a frozen copy of the claim. Every level must carry a non-empty stringsub. Withoutact, a non-emptyclient_idis the actor. With neither, the result is{ ok: true }with no actor. -
The outer level's
kindnames the actor:actSupabaseActorno 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_idand nokind, the shape older support tools minted, reads assupport. A support level withoutread_onlyis read-only. -
{ ok: false, reason: 'invalid-chain' }must deny. Anactthat is not a chain of objects ending in a non-emptysub, that names anotherkind, or a support level without a non-emptysession_idor with a non-booleanread_only, is not proof of who acts, and treating the token as the user alone would drop the delegation limit.actorOfalso returns it when reading the claims throws. -
delegationOfreads onlyscope: a space-separated string or a list of strings becomesscopes, without the OpenID Connect identity scopes, and an empty or malformed value, or one with only identity scopes, isundefined.subjectFromSupabasesetsdelegationonly for anoauth-clientactor, as{ scopes, chain }. A support or impersonation actor has none, so it reaches nothing until the policy delegates to it (support and impersonation actors). -
Callers apply the role rule first. A token with
roleset toanonorservice_rolemaps to the anonymous subject and never carries an actor or a delegation, whatever itsact,client_idorscopesay:anonis no user, andservice_roleis a bypass key, not a principal. Neither function looks atrole, 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
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 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:
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,
},
);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
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.
const session = await getSession(); // verified locally against the project JWKS
const subject = subjectFromSupabaseSession(session, { roles: "user_role" });Anonymous sign-ins
Supabase gives a signInAnonymously() 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:
const subject = subjectFromSupabaseSession(session, {
anonymousSignIns: "deny",
});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). In process, pass liveSession: true once this request checked the session against the Auth server:
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
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). 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 hook runs before Supabase Auth issues a token and may add or remove claims (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 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 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.
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:
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).
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).
Request lifecycle
- The client signs in; Supabase Auth runs the
custom_access_token_hookfrompermdock supabase hook generate, which readsuser_rolesand the membership sources and addsuser_role,membershipsand the tenant claim to the JWT. - Server request: the adapter in use (
permdock/nextover@supabase/ssr,permdock/honoover@supabase/server's Hono adapter, orwithPermDockafterwithClaimsin a pipeline) takes the claims Supabase verified, 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 evaluates the helpers (once per statement) 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; the browser never derives permissions from the JWT roles, even offline. Claims are stale until token refresh, so a role change should also triggerupdateTagand a session refresh.
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
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:
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, 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, joined on subject.session (session_id). With approvals set on the SSF factory, pending approval requests for that session are cancelled.
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_roleis a string or string array; anything else yields no roles. A role name the policy does not declare grants nothing (denial reasonunknown-role), anddeclareddrops such names at the boundary. Either way the result is fail closed: fewer grants, never more. app_metadataversususer_metadata: onlyapp_metadataclaims are exposed assubject.claims.permdock rls generate --rbac supabasederives the enums androle_permissionsseed rows from the policy at migration time, so they cannot disagree with it;--checkfails 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 deprecatedauth.role()is not mapped and staysopaque.permdock rls verifyruns fixtures withset local role authenticatedandrequest.jwt.claimscontaininguser_role.
How denials surface
- In-process: the shared contract on adapters.
- In Postgres: a helper that admits no role yields
filtered(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: 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). 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).
Related standards
- Postgres RLS:
auth.uid(),auth.jwt(),authorize(), rolesanon/authenticated/service_role, the RBAC guide, Splinter lints andsupabase gen typeslimits. - RLS adapter:
generate,import,verify. - Subject: principal fields and
subject.context. - Tenants, teams and scoped roles: memberships, the active tenant,
memberOfcompilation. - MCP adapter:
withOAuthProtectedResourcewith Supabase Auth as the OAuth 2.1 authorization server.
Last updated on
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.
Supabase token 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.