# better-supabase

Source: https://permdock.com/docs/adapters/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](https://bettersupabase.com/docs/extending/authorization-providers). 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](/docs/adapters/supabase) still applies: the hook, the helpers and the claims are the same with or without better-supabase.

## Why [#why]

* better-supabase has slots, not a PermDock mode. Its config takes an `authorization` provider, a bucket or topic takes an `access` policy, 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 way `permdock/better-auth` fills 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 --out` already writes `permdock.manifest.json` with the helper schema, the scopes, the membership tables and the hook's claims. `authorizationProvider` maps that file to the contract, so better-supabase's SQL and doctor follow the helpers `permdock rls generate` wrote without reading a PermDock file themselves.
* `permdock/supabase` stays plain Supabase. It names no better-supabase identifier, and an app on `@supabase/supabase-js` or `@supabase/server` alone never loads this entry.

## Who owns what [#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 [#api]

```ts
import {
  authorizationProvider,
  bucketPolicy,
  topicPolicy,
  apiKeyVerifier,
  apiKeyClaimOptions,
  subjectFromBetterSupabase,
  toolPolicy,
  credentialGuard,
} from "permdock/better-supabase";
```

* `authorizationProvider({ manifest, catalog?, scope?, approver? })` returns better-supabase's `AuthorizationProvider` (`apiVersion: 1`, `name: "PermDock"`). `manifest` is `permdock.manifest.json`, parsed or as JSON text; `catalog` is `permissions.catalog.json`. Without a catalog the provider lists no permissions, so better-supabase refuses every key a bucket, topic or module checks, and `problems` asks for it. `scope` names the tenant scope and defaults to the manifest's one root scope.
* Its `functions` call `permitted_<scope>_ids_by_permission`, `member_<scope>_ids`, `permdock_has_permission` and their `_for` forms in `rls.schema`. These take a permission key and subtract the instances a deny of it reaches, so a permission split into `#n` grant keys still matches. `permissionsFor` calls `permitted_<scope>_permission_keys_for`, so better-supabase's `member_permissions` lists a member's keys; like the other `_for` templates it is set only when the manifest lists the helper, which `rls.mode: 'database'` writes.
* `canAssign` and `canAssignFor` call `permdock_can_assign` and `permdock_can_assign_for`. When the manifest's `rls.customRoles` is set and the tenant scope is a root scope, they call `permdock_can_assign_any` and `permdock_can_assign_any_for` at the tenant scope instead, so better-supabase's `can_assign` and `can_assign_as` also answer for a custom role.
* `approver` is a permission. With it, `canApprove` lets a member who holds that permission in the tenant approve an AI tool call (`decide_ai_tool_approval`), and `approvals.distinctApprover` is `true`, so the requester never approves their own call. A key the catalog lacks is a problem.
* `scopes` carry each scope's `idType`: `uuid`, `text`, `bigint` or `integer`, with `int8`, `int` and `int4` normalised. Any other id type is a problem.
* `requires` lists each helper with the role that must execute it; `memberships`, `suspension` and `roleSources` come from `rls`; `tokenHook` names the hook function and the claims it owns. A manifest that cannot fill a field is recorded in `problems`, which better-supabase's doctor reports, and an invalid manifest throws a `TypeError`.
* `bucketPolicy(access, options)` and `topicPolicy(access, options)` return the `access` policy for `defineBucket` and `defineTopic`. `access` maps the operations (`read`, `list`, `write`, `delete` for a bucket; `receive`, `send` for a topic) to permissions, and `options` is `{ manifest, catalog, scope, segment? }` or `{ policy, schema?, scope, segment? }`. With `policy`, the `definePolicy` result 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, and `schema` names the helper schema (`rls.schema`, default `permdock`). The checks below are the same either way. They call `permitted_<scope>_ids_by_permission` and `permdock_has_permission`, which check role and scope only and subtract denies, so a permission the catalog marks `rowConditions: true` (or a conditioned grant in `policy`), or does not grant at `scope`, throws. `scope: "platform"` checks a permission granted globally.
* `apiKeyVerifier({ keys, manifest?, serviceRoles?, allPermissions? })` is a `CredentialVerifier` over better-supabase's `createApiKeys()`, for [`subjectFromApiKey`](/docs/concepts/credentials#using-a-key). A personal key is a `user` credential acting as its user, a tenant key a `service` credential holding `serviceRoles` in its tenant, and the key's scopes are the credential's permissions. `serviceRoles` defaults to the manifest's `rls.apiKeys.serviceRoles`. A rotated key in its grace period verifies, and its credential's `expiresAt` is 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 to `null`.
* `apiKeyClaimOptions(manifest)` returns the `claim` and `tenantClaim` options of better-supabase's `apiKeyClaims()` and `apiKeyResolver()`, from the manifest's `rls.apiKeys`, so a key's token carries the scopes ceiling, tenant and roles the helpers read. It throws when the manifest has no `rls.apiKeys`.
* `subjectFromBetterSupabase(session, options?)` is `subjectFromSupabaseSession` with better-supabase's defaults: `memberships` for the memberships and the entitlements module's `features` claim for `principal.plans`. A `user` session maps through `subjectFromSupabase`. With `apiKeys: { permissions, manifest?, serviceRoles? }`, an `apiKey` session maps like `apiKeyVerifier`'s credential; without it, and for `anon`, `service` and `invalid` sessions, the subject is anonymous. `plans: { claim?, keys? }` decodes the short codes `entitlements.claim.keys` writes back to feature keys. Other options override the defaults.
* `toolPolicy({ permdock, data? })` returns the `authorize` and `visible` hooks of `createMcp` for tools whose `meta` is a permission ([MCP tools](#mcp-tools)).
* `credentialGuard(provider, { permdock, use, revoke? })` wraps a `CredentialProvider` so PermDock decides each token use ([Credentials](#credentials)).

## Setup [#setup]

Generate the helpers, the hook and the manifest first, as on the [Supabase page](/docs/adapters/supabase):

```bash
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.json
```

Then hand the provider to better-supabase. The config runs in Node, so it reads the two files directly:

```ts title="better-supabase.config.ts"
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 [#claims]

Validate sessions with the claim contract's Standard Schema, extended with the app's own claims:

```ts title="src/lib/supabase/index.ts"
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 [#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`:

```ts title="permdock.config.ts"
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](/docs/adapters/supabase-hook#claims-other-packages-own)).

## Server code [#server-code]

```ts title="src/lib/access.ts"
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](/docs/adapters/hono), [oRPC](/docs/adapters/orpc) and `@supabase/middleware` adapters: map `c.get("auth")`, `context.auth` or the session the middleware put on the context.

## Storage and Realtime [#storage-and-realtime]

```ts
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 [#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:

```ts
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:

```ts
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:

```ts
const subject = subjectFromBetterSupabase(session, {
  apiKeys: { permissions, manifest },
});
```

## MCP tools [#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:

```ts title="supabase/functions/mcp/index.ts"
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`](/docs/adapters/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 [#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.

```ts
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 [#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`](/docs/adapters/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 [#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 [#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`](/docs/cli/rls#triggers-and-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:

```ts title="permdock.config.ts"
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 [#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 [#sessions-and-actors]

* better-supabase's `checkSession(sql, auth)` checks that a token's session is still in `auth.sessions`. Call it before a sensitive action and pass the result as `liveSession` ([live sessions](/docs/adapters/supabase#live-sessions)).
* A support session and `actingAs` write `act` with `kind: "support"` or `"impersonation"`, so the subject's actor has that kind and every call denies with `no-delegation` until the policy names the actor in a [delegation](/docs/security/delegation#policy-delegations) ([support and impersonation actors](/docs/adapters/supabase#support-and-impersonation-actors)).
* The `scopes` guard answers 403 `insufficient_scope` before PermDock runs; PermDock's delegation narrowing still applies after it.
* Only `allow: ["anonymous"]` admits a `signInAnonymously()` user past a guard. Pair the default `allow: ["user"]` with `anonymousSignIns: "deny"` ([anonymous sign-ins](/docs/adapters/supabase#anonymous-sign-ins)).

## Testing [#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 [#example-app]

`apps/examples/next-better-supabase`: better-supabase on Next.js 16.3 with Cache Components.

* `better-supabase.config.ts` passes `authorizationProvider` the example's `permdock.manifest.json` and `permissions.catalog.json`.
* `src/lib/supabase/index.ts` validates sessions with `supabaseClaims().extend(appClaims)`, and `src/lib/access.ts` maps them with `subjectFromBetterSupabase`.
* `supabase/schemas/public/functions/feature_claims.sql` adds `public.feature_claims(user_id)`, which reads `organization_features` for the organizations `permdock.member_organization_ids_for(user_id)` returns. `permdock.config.ts` registers it as the `features` claim.
* `supabase/tests` holds pgTAP tests over the seeded tenants, and `tests/claims.test.ts` maps every claim fixture through better-supabase's `createServer`.

## Related [#related]

* [Supabase](/docs/adapters/supabase): the claims, helpers and middleware this entry builds on.
* [Supabase token hook](/docs/adapters/supabase-hook): the one hook and `supabase.hook.claims`.
* [RLS adapter](/docs/adapters/rls): the SQL helper contract the provider's templates call.
