tenant
Scope every query to the request's tenant, on top of RLS.
import { tenant } from "better-supabase/plugins/tenant";
const betterSupabase = defineSupabase(schema).use(tenant());
const db = betterSupabase.connect(supabase, { claims }); // or { tenant: organizationId }For tables with the tenant flag:
- reads, updates and deletes get
organization_id = <tenant>, including includes and relation filters; - inserts get the tenant column filled in;
- writing a row for another tenant, or moving a row to one, fails with
forbiddenbefore anything is sent. A numeric tenant column matches when its text equals the tenant; - an upsert that updates on conflict needs the tenant column in its conflict
target (
onConflict: ["organizationId", "kvk"]or a unique constraint that includes it), so it cannot overwrite another tenant's row. The SQL and SQLite executors also addwhere <tenant> = excluded.<tenant>to the update for every table generated with the tenant flag, with or without this plugin. PostgREST has no such clause, so on the default executor the table's RLS update policy is what stops a cross-tenant upsert.
The tenant it resolves becomes db.$context.tenant, so jobs, storage and
cache invalidation that receive db.$context use the same tenant.
The tenant comes from context.tenant, then the JWT claim, or from your own
resolve(context). claim takes one or more dotted paths and defaults to
['tenant_id', 'app_metadata.tenant_id']: a top-level claim from a custom
access token hook, or app_metadata set through the
Auth admin API. Set claims.tenant in the config to rename the claim for the
plugin, current_tenant_id(), and the storage and realtime policies together.
user_metadata is never a source, because users can write it. Server adapters
fill the context from the verified JWT.
A tenant from a URL or request body is only safe after you check it against
the user's memberships. Do that in resolve, or pass { tenant } to
betterSupabase.connect once it is checked.
In the API document
defineApi marks the tenant column of every served tenant
table readOnly, described as "Set from the request's tenant.", and drops
it from the required fields of request bodies. When resolve reads the
tenant from a header, name it in header so the document lists it:
tenant({
header: "X-Organization",
resolve: (context) => checkedOrganization(context),
});Each operation on a tenant table then has an X-Organization header
parameter, "The tenant the request acts for.", required unless
onMissing: 'skip'. The plugin doesn't read the header itself; resolve
does.
Pass your claims type to check the paths at compile time. Only paths to string claims, up to three levels deep, are accepted:
const betterSupabase = defineSupabase(schema)
.claims(Claims)
.use(
tenant<v.InferOutput<typeof Claims>>({ claim: "app_metadata.tenant_id" }),
);Missing tenant
By default a query on a tenant table without a tenant fails with
forbidden: the plugin fails closed. That includes a query on another table
whose includes or relation filters reach a tenant table. Use onMissing: 'skip' to leave it
to RLS, or { allTenants: true } per call for admin tooling:
await adminDb.customers.count({ allTenants: true });Not a replacement for RLS
The plugin makes queries correct and indexes usable; RLS makes them safe. Keep
a tenant policy on every table. better-supabase doctor checks both.
Last updated on