PermDock
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: 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 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 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).
  • supabaseApprovalStore(client, { schema?, ttl?, onOpen? }) is an ApprovalStore over the table and functions rls.approvals generates (a generated store).
  • 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).
  • 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

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 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, portable compilation). memberships is one table, or { scopes, tenant, team, resource } with a table per named scope. suspension names the users and scope-instance tables whose disabledAt or status column suspends a row (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 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).
  • 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: 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);
    • 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). 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 fieldSource in the JWTIn generated policies
principal.idsub(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 claimNarrows the tenant helper to that tenant; in a condition, org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid
principal.membershipsA hook-injected memberships claim, or a MembershipSource over your tablesorg_id in (select permitted_tenant_ids('perm')), reading supabaseRls({ memberships }) (database) or the claim (jwt)
principal.claim.planapp_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 link holderrole = 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 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). context(ctx, request), onDenied and wrap behave as on the server kernel.
  • 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). 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).
  • permdockHandler() returns a terminal handler for POST AuthZEN evaluations 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.
  • 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).

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 arePass to subjectFromSupabaseNotes
withSupabase({ auth: 'user' }, (req, ctx) => …)ctx.jwtClaimsauth: 'user' rejects token-less requests with 401 before the handler runs
A standalone pipeline([withClaims()], …)ctx.jwtClaimswithClaims contributes null for anonymous callers; withRequiredClaims short-circuits them with 401
Hono, H3, Elysia or NestJS through a bridgectx.jwtClaimsThe 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 namenullAn API key is not a user. The subject is anonymous
authMode: 'secret' with a key named in secretKeysnull (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 service credential 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 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

@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/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 instead 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):

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.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 })VerifiedNot copied into principal.assurance.amr (those objects are not RFC 8176 strings). Use aal for MFA, or map method yourself in a wrapper
issVerifiedprincipal.issuer; the project's Auth URL
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. With anonymousSignIns: 'deny', is_anonymous: true yields the anonymous subject
The claim options.plans names (for example features), an object keyed by tenant idServer-set, trustedprincipal.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)Verifiedsubject.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)Verifiedsubject.actor { id: client_id, kind: 'oauth-client' } when there is no act
scopeVerifieddelegation.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
  • 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:

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

  • 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

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:

ClaimSourceCounts toward the budget
user_role, rolessupabase.hook.roles (default rls.roles, else <schema>.user_roles; role: { through, on, column } reads keys through a roles table); [] for a suspended userNo
membershipssupabase.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 tableYes
memberships_truncatedSet to true when memberships or attrs were cut to fitNo
tenant_id (rls.tenantClaim)supabase.hook.activeFrom, when the user holds a membership thereNo
attrssupabase.hook.attrs, when configuredYes
authz_verpermdock_authz_version, unless version: falseNo
each supabase.hook.claims keyIts <schema>.<function>(uuid); left out for a suspended userNo

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:

ModeThe helpers and authorize() readA role change reaches RLSA role change reaches principal.roles
database (default)user_roles and the membership tableOn the next statementOn the next token refresh, unless the app loads memberships itself (snapshotFor(policy, claims, { memberships }))
jwtThe hook-injected claimsOn the next token refreshOn 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:

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

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

  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

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_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

  • In-process: the shared contract on 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 or an app-level updateTag on 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).

  • Postgres RLS: auth.uid(), auth.jwt(), authorize(), roles anon / authenticated / service_role, the RBAC guide, Splinter lints and supabase gen types limits.
  • RLS adapter: generate, import, verify.
  • Subject: principal fields and subject.context.
  • Tenants, teams and scoped roles: memberships, the active tenant, memberOf compilation.
  • MCP adapter: withOAuthProtectedResource with Supabase Auth as the OAuth 2.1 authorization server.

Last updated on

On this page