better-supabase
The permdock/better-supabase entry fills better-supabase's authorization slots with PermDock, an authorization provider for its SQL modules and doctor, bucket and topic policies, API keys, MCP tool hooks, a credential guard, and a subject from its session.
permdock/better-supabase connects PermDock to better-supabase. better-supabase verifies tokens, builds sessions and owns the data layer; PermDock owns roles, permissions, the claim contract and the one token hook. better-supabase never imports PermDock: it reads a versioned AuthorizationProvider object from its config, and this entry builds that object and the other values its slots take from the files PermDock already generates.
The entry needs better-supabase 0.6 or later as a peer. Everything on the Supabase page still applies: the hook, the helpers and the claims are the same with or without better-supabase.
Why
- better-supabase has slots, not a PermDock mode. Its config takes an
authorizationprovider, a bucket or topic takes anaccesspolicy, and its API key block takes a claim and a verifier. Each slot is a neutral, versioned contract (apiVersion: 1), so PermDock fills it the waypermdock/better-authfills Better Auth's role source, and any other authorization package could fill it too. - The provider is data built from the manifest.
permdock supabase inspect --outalready writespermdock.manifest.jsonwith the helper schema, the scopes, the membership tables and the hook's claims.authorizationProvidermaps that file to the contract, so better-supabase's SQL and doctor follow the helperspermdock rls generatewrote without reading a PermDock file themselves. permdock/supabasestays plain Supabase. It names no better-supabase identifier, and an app on@supabase/supabase-jsor@supabase/serveralone never loads this entry.
Who owns what
| Concern | Owner |
|---|---|
Token verification (@supabase/server), sessions, refresh and the AuthSession shape | better-supabase |
| Typed clients, repositories and typegen | better-supabase |
Storage and Realtime plumbing (defineBucket, defineTopic), jobs and webhooks | better-supabase |
MCP protected-resource metadata and the scope guards (createMcp, createMcpAuth) | better-supabase |
| Token lifetime, refresh-token reuse, signing keys, splinter and claim size lints | better-supabase |
The claim contract: user_role, roles, memberships, the active-tenant claim, attrs, authz_ver, memberships_truncated | PermDock |
The one token hook (permdock supabase hook generate) | PermDock |
role_permissions, authorize(), the SQL helpers and the table policies | PermDock |
| Tool and route authorization | PermDock |
API
import {
authorizationProvider,
bucketPolicy,
topicPolicy,
apiKeyVerifier,
apiKeyClaimOptions,
subjectFromBetterSupabase,
toolPolicy,
credentialGuard,
} from "permdock/better-supabase";authorizationProvider({ manifest, catalog?, scope?, approver? })returns better-supabase'sAuthorizationProvider(apiVersion: 1,name: "PermDock").manifestispermdock.manifest.json, parsed or as JSON text;catalogispermissions.catalog.json. Without a catalog the provider lists no permissions, so better-supabase refuses every key a bucket, topic or module checks, andproblemsasks for it.scopenames the tenant scope and defaults to the manifest's one root scope.- Its
functionscallpermitted_<scope>_ids_by_permission,member_<scope>_ids,permdock_has_permissionand their_forforms inrls.schema. These take a permission key and subtract the instances a deny of it reaches, so a permission split into#ngrant keys still matches.permissionsForcallspermitted_<scope>_permission_keys_for, so better-supabase'smember_permissionslists a member's keys; like the other_fortemplates it is set only when the manifest lists the helper, whichrls.mode: 'database'writes. canAssignandcanAssignForcallpermdock_can_assignandpermdock_can_assign_for. When the manifest'srls.customRolesis set and the tenant scope is a root scope, they callpermdock_can_assign_anyandpermdock_can_assign_any_forat the tenant scope instead, so better-supabase'scan_assignandcan_assign_asalso answer for a custom role.approveris a permission. With it,canApprovelets a member who holds that permission in the tenant approve an AI tool call (decide_ai_tool_approval), andapprovals.distinctApproveristrue, so the requester never approves their own call. A key the catalog lacks is a problem.scopescarry each scope'sidType:uuid,text,bigintorinteger, withint8,intandint4normalised. Any other id type is a problem.requireslists each helper with the role that must execute it;memberships,suspensionandroleSourcescome fromrls;tokenHooknames the hook function and the claims it owns. A manifest that cannot fill a field is recorded inproblems, which better-supabase's doctor reports, and an invalid manifest throws aTypeError.bucketPolicy(access, options)andtopicPolicy(access, options)return theaccesspolicy fordefineBucketanddefineTopic.accessmaps the operations (read,list,write,deletefor a bucket;receive,sendfor a topic) to permissions, andoptionsis{ manifest, catalog, scope, segment? }or{ policy, schema?, scope, segment? }. Withpolicy, thedefinePolicyresult the app already imports, nothing is read at runtime: the scopes, the permissions with row conditions and the scopes each permission is granted at come from the definitions, andschemanames the helper schema (rls.schema, defaultpermdock). The checks below are the same either way. They callpermitted_<scope>_ids_by_permissionandpermdock_has_permission, which check role and scope only and subtract denies, so a permission the catalog marksrowConditions: true(or a conditioned grant inpolicy), or does not grant atscope, throws.scope: "platform"checks a permission granted globally.apiKeyVerifier({ keys, manifest?, serviceRoles?, allPermissions? })is aCredentialVerifierover better-supabase'screateApiKeys(), forsubjectFromApiKey. A personal key is ausercredential acting as its user, a tenant key aservicecredential holdingserviceRolesin its tenant, and the key's scopes are the credential's permissions.serviceRolesdefaults to the manifest'srls.apiKeys.serviceRoles. A rotated key in its grace period verifies, and its credential'sexpiresAtis the end of the grace period when that comes before the key's own expiry. An invalid, revoked, expired or rate-limited key, and a failed lookup, verify tonull.apiKeyClaimOptions(manifest)returns theclaimandtenantClaimoptions of better-supabase'sapiKeyClaims()andapiKeyResolver(), from the manifest'srls.apiKeys, so a key's token carries the scopes ceiling, tenant and roles the helpers read. It throws when the manifest has norls.apiKeys.subjectFromBetterSupabase(session, options?)issubjectFromSupabaseSessionwith better-supabase's defaults:membershipsfor the memberships and the entitlements module'sfeaturesclaim forprincipal.plans. Ausersession maps throughsubjectFromSupabase. WithapiKeys: { permissions, manifest?, serviceRoles? }, anapiKeysession maps likeapiKeyVerifier's credential; without it, and foranon,serviceandinvalidsessions, the subject is anonymous.plans: { claim?, keys? }decodes the short codesentitlements.claim.keyswrites back to feature keys. Other options override the defaults.toolPolicy({ permdock, data? })returns theauthorizeandvisiblehooks ofcreateMcpfor tools whosemetais a permission (MCP tools).credentialGuard(provider, { permdock, use, revoke? })wraps aCredentialProviderso PermDock decides each token use (Credentials).
Setup
Generate the helpers, the hook and the manifest first, as on the Supabase page:
permdock rls generate --target sql --out supabase/migrations/<n>_permdock_rls.sql
permdock supabase hook generate
permdock supabase inspect --out
permdock catalog --out permissions.catalog.jsonThen hand the provider to better-supabase. The config runs in Node, so it reads the two files directly:
import { readFileSync } from "node:fs";
import { defineConfig } from "better-supabase/config";
import { authorizationProvider } from "permdock/better-supabase";
const read = (file: string) =>
readFileSync(new URL(file, import.meta.url), "utf8");
export default defineConfig({
authorization: authorizationProvider({
manifest: read("permdock.manifest.json"),
catalog: read("permissions.catalog.json"),
}),
sql: { modules: { access: { model: "provider" }, organizations: {} } },
});Run better-supabase sql sync and better-supabase gen after a change to the policy, so the rendered SQL and the provider agree. Rerun permdock supabase inspect --out first.
Claims
Validate sessions with the claim contract's Standard Schema, extended with the app's own claims:
import { defineSupabase } from "better-supabase";
import { supabaseClaims } from "permdock/supabase";
import { z } from "zod";
import { schema } from "./generated";
export const betterSupabase = defineSupabase(schema).claims(
supabaseClaims().extend(
z.object({ datetime_preferences: z.object({ timezone: z.string() }) }),
),
);A token whose claims fail either schema resolves to an invalid session. Pass supabaseClaims({ tenantClaim }) when rls.tenantClaim is not tenant_id, and set the same name in better-supabase's claims.tenant.
Memberships
PermDock owns the memberships. Keep them in the app's own tables and describe them with PermDock sources. Don't run better-supabase sql add tenant, whose membership_claims() would write a second set of tenant claims: it refuses while the provider's tokenHook.ownedClaims lists memberships, and better-supabase's doctor reports BS407 for a hook that writes them next to PermDock's.
The entitlements module's features claim goes through supabase.hook.claims:
import { fromJunction } from "permdock/supabase";
export default {
supabase: {
hook: {
memberships: [
fromJunction({
table: "app.memberships",
scope: "organization",
id: "organization_id",
roles: "role",
}),
],
claims: { features: "public.feature_claims" },
},
},
};With the provider set, the entitlements module reads membership through member_<scope>_ids and its _for form, so an expired membership or a suspended organization loses its features in the same token that loses the membership (claims other packages own).
Server code
import { createPermDock } from "permdock";
import { subjectFromBetterSupabase } from "permdock/better-supabase";
import { policy } from "../policy";
import { bs } from "./supabase/server";
export async function permdockFor() {
const session = await bs.session();
return createPermDock(policy, subjectFromBetterSupabase(session));
}The same subject works in the Hono, oRPC and @supabase/middleware adapters: map c.get("auth"), context.auth or the session the middleware put on the context.
Storage and Realtime
import { defineBucket } from "better-supabase/storage";
import { bucketPolicy } from "permdock/better-supabase";
export const documents = defineBucket({
id: "documents",
path: "{organizationId}/{name}",
policy: bucketPolicy(
{ read: permissions.documents.browse, write: permissions.documents.upload },
{ policy, scope: "organization" }, // or { manifest, catalog, scope }
),
});Pass policy when the bucket file can import the app's policy: no JSON file is read at runtime, and bucketPolicy fails at definition time on a key with a conditioned grant. Pass manifest and catalog when the file cannot import the policy, for example because the policy module does not load where better-supabase reads its config.
A permission's row conditions (such as ownerId = principal.id) are applied by the table policies, not by the helpers, so bucketPolicy refuses a key the catalog marks rowConditions: true. Grant browse-style permissions per scope with no row condition for objects. permdock doctor PD037 reports a storage.objects or realtime.messages policy that calls a helper for such a key.
API keys
PermDock parses better-supabase keys only when the block uses PermDock's prefix: pass prefix: "pdk" to createApiKeys. The format is the same, checksum included. The credential's id is the key's public id, the part between the prefix and the secret.
Use apiKeyClaimOptions where better-supabase turns a key into a token, so the token carries the claims the helpers read:
import { apiKeyResolver, createApiKeys } from "better-supabase/blocks/api-keys";
import { apiKeyClaimOptions } from "permdock/better-supabase";
const keys = createApiKeys({ transport, prefix: "pdk" });
const resolver = apiKeyResolver({
keys,
...apiKeyClaimOptions(manifest),
serviceRoles: () => ["integration"],
});Use apiKeyVerifier where PermDock reads the key itself, for example a route outside better-supabase's middleware:
import { apiKeyVerifier } from "permdock/better-supabase";
import { subjectFromApiKey } from "permdock/server";
const resolveKey = subjectFromApiKey({
verifier: apiKeyVerifier({
keys,
manifest,
allPermissions: Object.values(permissions),
}),
permissions,
});
const subject = await resolveKey(request.headers.get("x-api-key"));permdock/server exports an apiKeyVerifier too, over PermDock's own key store. This one reads better-supabase's api_keys table instead; import the one that matches where the keys live.
When better-supabase's middleware already verified the key, the session is kind: "apiKey". Pass apiKeys to subjectFromBetterSupabase to map it the same way:
const subject = subjectFromBetterSupabase(session, {
apiKeys: { permissions, manifest },
});MCP tools
permdock/mcp wraps the official MCP SDK's server, so it cannot wrap better-supabase's createMcp. With createMcp, put the permission in each tool's meta and spread toolPolicy into the options. A tool without a permission is hidden and refused, and a check that throws refuses:
import { createMcp } from "better-supabase/mcp";
import { createPermDock } from "permdock";
import {
subjectFromBetterSupabase,
toolPolicy,
} from "permdock/better-supabase";
import { betterSupabase } from "../_shared/supabase.ts";
import { permissions, policy } from "../_shared/policy.ts";
type Auth = Parameters<typeof subjectFromBetterSupabase>[0];
type Instance = Awaited<ReturnType<typeof createPermDock>>;
// One PermDock instance per request: `visible` runs once per tool.
const instances = new WeakMap<object, Promise<Instance>>();
function permdockFor(ctx: { auth: Auth }): Promise<Instance> {
let permdock = instances.get(ctx.auth);
if (!permdock) {
permdock = createPermDock(policy, subjectFromBetterSupabase(ctx.auth));
instances.set(ctx.auth, permdock);
}
return permdock;
}
const server = createMcp(betterSupabase, {
name: "crm",
version: "1.0.0",
advertisedScopes: ["openid"],
...toolPolicy({ permdock: permdockFor }),
}).tool({
name: "export_customers",
description: "Export the organisation's customers as CSV.",
meta: permissions.customers.export,
run: (_args, { db }) => db.customers.findMany(),
});visible uses mayUse, so a tool is listed when some grant could apply, including one with a row condition. authorize decides with decide on the row data(ctx, tool, args) returns, or on no row, which a row-scoped permission denies; check those inside run or rely on RLS. A permission that needs approval is refused, because createMcp has no approval store: serve those tools through permdock/mcp on the official SDK, with better-supabase's createMcpAuth from better-supabase/mcp/sdk for the bearer token and metadata. tests/better-supabase/mcp.test-d.ts type-checks this recipe against better-supabase/mcp.
Credentials
credentialGuard wraps a better-supabase CredentialProvider, the interface its AI and integration blocks fetch third-party tokens through. Every getToken, startAuthorization and completeAuthorization is decided against use, and revoke against revoke or use, with the CredentialRef as the row. A denial or a failed check returns a forbidden error with hint PERMDOCK_DENIED; capabilities and verifyInbound pass through.
import { credentialGuard } from "permdock/better-supabase";
const credentials = credentialGuard(vault, {
permdock: () => permdockFor(request),
use: permissions.integrations.use,
revoke: permissions.integrations.manage,
});Build it per request, with that request's PermDock. testCredentialProvider from better-supabase/testing accepts the guarded provider.
Agents and tool approval
better-supabase's createAgentRuntime({ toolApproval }) from better-supabase/ai-sdk/agents takes an AI SDK tool-approval function and combines it with the tenant's ask tools; the stricter answer wins. toolApproval and composeToolApproval(...) from permdock/ai-sdk are such functions: a call PermDock answers approval-required asks for the user's approval, and a denied call is refused. tests/better-supabase/agents.test-d.ts type-checks the pairing.
Module permission keys
better-supabase's SQL modules check permission keys, such as audit.read, api_keys.manage and the AI, workflow and inbox modules' keys. Its doctor reports a key the provider's permissions lacks, so pass catalog and declare each key the installed modules check in the policy. Rename a key with sql.modules.<module>.permissions in better-supabase.config.ts.
Audit the PermDock tables
better-supabase's audit module registers a table with better_supabase.audit(target regclass, …), which creates the audit trigger and reads the table's primary key. rls.audit writes that call into the generated SQL for the custom-role tables, so every save through permdock_replace_custom_role_grants leaves an audit row:
rls: {
customRoles: true,
audit: {
function: "better_supabase.audit",
tables: ["custom_role_permissions", "custom_role_includes"],
args: { category: "permissions", tenant_column: "tenant_id", label_column: "role" },
},
},The arguments apply to every listed table, so user_roles, which has no tenant_id, needs a second call of your own or no tenant_column. Apply better-supabase's migrations before PermDock's, because the function must exist when the call runs.
When to use bucketPolicy and topicPolicy
bucketPolicy and topicPolicy decide in better-supabase's Storage and Realtime policies through the provider's helpers, for buckets and topics defined with defineBucket and defineTopic. PermDock's own rls.storage and rls.realtime write the policies on storage.objects and realtime.messages from permdock rls generate instead. Pick one per bucket or topic: both on the same one write two policies that Postgres combines with or.
Sessions and actors
- better-supabase's
checkSession(sql, auth)checks that a token's session is still inauth.sessions. Call it before a sensitive action and pass the result asliveSession(live sessions). - A support session and
actingAswriteactwithkind: "support"or"impersonation", so the subject's actor has that kind and every call denies withno-delegationuntil the policy names the actor in a delegation (support and impersonation actors). - The
scopesguard answers 403insufficient_scopebefore PermDock runs; PermDock's delegation narrowing still applies after it. - Only
allow: ["anonymous"]admits asignInAnonymously()user past a guard. Pair the defaultallow: ["user"]withanonymousSignIns: "deny"(anonymous sign-ins).
Testing
testAuthorizationProvider from better-supabase/testing checks a provider against the contract; tests/better-supabase/provider.test.ts runs it on the provider built from a fixture manifest. tests/integration/src/better-supabase-provider.test.ts applies the example's migrations to Postgres and checks that every requires entry exists with its grant and every template runs at every scope. tests/integration/src/better-supabase-assignments.test.ts runs the canAssign templates against PGlite for declared and custom roles. supabaseClaimFixtures.tenantPlans in permdock/testing is a token with features claims for subjectFromBetterSupabase.
Example app
apps/examples/next-better-supabase: better-supabase on Next.js 16.3 with Cache Components.
better-supabase.config.tspassesauthorizationProviderthe example'spermdock.manifest.jsonandpermissions.catalog.json.src/lib/supabase/index.tsvalidates sessions withsupabaseClaims().extend(appClaims), andsrc/lib/access.tsmaps them withsubjectFromBetterSupabase.supabase/schemas/public/functions/feature_claims.sqladdspublic.feature_claims(user_id), which readsorganization_featuresfor the organizationspermdock.member_organization_ids_for(user_id)returns.permdock.config.tsregisters it as thefeaturesclaim.supabase/testsholds pgTAP tests over the seeded tenants, andtests/claims.test.tsmaps every claim fixture through better-supabase'screateServer.
Related
- Supabase: the claims, helpers and middleware this entry builds on.
- Supabase token hook: the one hook and
supabase.hook.claims. - RLS adapter: the SQL helper contract the provider's templates call.
Last updated on
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.
Better Auth
The permdock/better-auth provider builds a PermDock subject from Better Auth sessions and organization roles, including dynamic database roles, so PermDock's conditions, snapshots and adapters layer on top of Better Auth access control.