SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 17, 2026·Security·SecureStartKit Team

Supabase Multi-Tenancy + RBAC: The Secure Pattern [2026]

Multi-tenancy and RBAC in Supabase + Next.js. Tenant scoping via JWT claims + RLS, the composite index rule, and five cross-tenant leak modes.

Summarize with AI

On this page

  • Table of contents
  • When does a Next.js + Supabase app actually need multi-tenancy?
  • Three isolation models, and when to pick each
  • How do you add `tenant_id` to the schema without breaking everything?
  • How do you inject `tenant_id` and `role` into the JWT?
  • RLS patterns: tenant isolation + role-based permissions
  • How do you scope every Server Action to the current tenant?
  • Performance: the composite index rule
  • Five multi-tenancy mistakes that ship cross-tenant data leaks
  • What this means for your Next.js + Supabase architecture

On this page

  • Table of contents
  • When does a Next.js + Supabase app actually need multi-tenancy?
  • Three isolation models, and when to pick each
  • How do you add `tenant_id` to the schema without breaking everything?
  • How do you inject `tenant_id` and `role` into the JWT?
  • RLS patterns: tenant isolation + role-based permissions
  • How do you scope every Server Action to the current tenant?
  • Performance: the composite index rule
  • Five multi-tenancy mistakes that ship cross-tenant data leaks
  • What this means for your Next.js + Supabase architecture

Supabase multi-tenancy and RBAC in Next.js comes down to four architectural decisions: a tenant_id column on every isolated table, a Custom Access Token Hook that injects tenant_id and role into the JWT [2], RLS policies that match the column against auth.jwt() claims [1], and Server Actions that read the tenant from the validated session rather than the request payload. That sequence is the difference between a B2B SaaS that scales to thousands of orgs without cross-tenant leaks and one that ships with a single RLS bug that can expose every row in the database.

This pillar covers the column-based shared-schema pattern, which fits roughly 80 percent of B2B SaaS in 2026. The supporting clusters cover the primitives: the RLS patterns guide for policy syntax, the backend-only data access pattern for the architectural foundation, and the OWASP Top 10 pillar for the broken-access-control category that cross-tenant leaks fall under.

TL;DR:

  • Shared-schema with tenant_id is the right default. Schema-per-tenant and DB-per-tenant are for compliance-driven cases (HIPAA, on-prem). Column-based with RLS handles 10K+ orgs on a single Supabase instance.
  • JWT claims, not session lookups. Put tenant_id and role into the JWT via a Custom Access Token Auth Hook. RLS policies read them with auth.jwt() ->> 'tenant_id'. No database round-trip per query.
  • Composite indexes are non-negotiable. Every isolated table needs tenant_id as the leading column in its primary access indexes. Missing this is the #1 perf killer: 120ms vs 1.2ms at scale.
  • WITH CHECK clauses prevent the insertion bug. Without WITH CHECK, a user can insert rows tagged with another tenant's ID and bypass USING clauses on reads.
  • Tenant ID comes from getClaims(), never from the payload. A Server Action that reads tenantId from formData and uses it as a query predicate is the canonical cross-tenant leak primitive.

Table of contents

  • When does a Next.js + Supabase app actually need multi-tenancy?
  • Three isolation models, and when to pick each
  • How do you add tenant_id to the schema without breaking everything?
  • How do you inject tenant_id and role into the JWT?
  • RLS patterns: tenant isolation + role-based permissions
  • How do you scope every Server Action to the current tenant?
  • Performance: the composite index rule
  • Five multi-tenancy mistakes that ship cross-tenant data leaks
  • What this means for your Next.js + Supabase architecture

When does a Next.js + Supabase app actually need multi-tenancy?

Most B2C apps don't. A solo-developer SaaS where each user is the unit of billing, data, and access doesn't need tenant scoping; the existing user_id foreign key does the same job. Multi-tenancy starts mattering when one logical organization has multiple users who share data: a team plan, a workspace, an agency with client accounts.

Three signals that tell you the architecture needs to change:

  • The billing unit and the data unit diverge. One Stripe customer pays for one subscription, but five people inside that customer's account need read/write access to the same rows. The customer is now a tenant, not a user.
  • Users belong to multiple tenants. A consultant working with three client companies needs to switch context: same login, different data scope. The session has to carry "which tenant am I acting on behalf of right now," not just "who am I."
  • RBAC inside a tenant matters. An admin in tenant A can do things a member in tenant A cannot, AND neither can read data from tenant B. Two orthogonal access dimensions: tenant scope and role permissions.

If only the third signal applies (one user, multiple roles, no shared org), you don't actually need multi-tenancy; you need RBAC on the existing user-scoped data. If only the first applies (multiple users sharing data, no per-user roles), you can ship a simpler shared-team model without full multi-tenancy.

The shape this pillar covers is the full case: tenants own data, users belong to tenants, users have roles within tenants. The architectural primitives below are the same whether you call them tenants, organizations, workspaces, teams, or accounts.

Three isolation models, and when to pick each

Postgres-on-Supabase supports three multi-tenancy isolation models. The right choice depends on tenant count, compliance constraints, and operational complexity tolerance.

Column-based (shared schema with tenant_id). One database, one schema, every isolated table has a tenant_id column, and RLS policies enforce isolation. This is the default for the vast majority of B2B SaaS in 2026; pooled multi-tenant SaaS with RLS handles roughly 80 percent of cases at 10K+ tenants on a single Supabase instance [4]. Cheapest to operate (one DB to back up, migrate, monitor), simplest to query across tenants for admin/analytics, and Supabase's primitives (RLS, real-time, storage) all respect the pattern natively.

Schema-per-tenant. One database, one Postgres schema per tenant, each schema has its own copy of the tables. Better isolation than columns (a buggy RLS policy in tenant A's schema cannot leak into tenant B's), but migrations have to apply to every schema, cross-tenant analytics requires UNION ALL gymnastics, and the schema count becomes an operational problem above a few hundred tenants. Worth it when individual tenants need different schema versions or you need per-tenant data residency claims.

Database-per-tenant. One Supabase project per tenant. Maximum isolation, justifiable for healthcare, finance, or compliance regimes that require it explicitly (HIPAA, FedRAMP). Operationally expensive: separate billing, separate connection pooling, separate deploys. Rarely the right answer below "we're under contract to do this" thresholds.

For the rest of this pillar, the column-based shared-schema pattern is the assumed model. The principles transfer if you pick one of the other two, but the code is different.

How do you add tenant_id to the schema without breaking everything?

Two new tables hold the tenant-and-membership graph:

-- tenants: the org/workspace itself
CREATE TABLE public.tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- memberships: which users belong to which tenants, with what role
CREATE TABLE public.memberships (
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (user_id, tenant_id)
);

-- Index for the JWT hook lookup (one row per user_id)
CREATE INDEX idx_memberships_user_id ON public.memberships(user_id);

Then every isolated table gets a tenant_id column with a foreign key reference:

CREATE TABLE public.projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
  created_by UUID NOT NULL REFERENCES auth.users(id),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Composite index: tenant_id MUST be the leading column for RLS perf
CREATE INDEX idx_projects_tenant_id ON public.projects(tenant_id, created_at DESC);

A few details worth being explicit about. tenant_id is NOT NULL with a foreign key, never optional, never a string that could be empty. The ON DELETE CASCADE is opinionated: if a tenant gets deleted, all their data goes with it. For compliance-sensitive cases you'd switch to ON DELETE RESTRICT and explicitly soft-delete tenants instead. The memberships composite primary key (user_id, tenant_id) means a given user can hold exactly one role per tenant, which is almost always what you want.

The migration order matters when adding multi-tenancy to an existing single-tenant database. Add tenant_id as a nullable column first, backfill it from the existing user data (every row gets the user's default-tenant ID), then ALTER to NOT NULL and add the foreign key. RLS policies stay disabled during the backfill, then enable in a single transaction.

How do you inject tenant_id and role into the JWT?

The Custom Access Token Hook is the right place to do this [2]. Supabase calls a Postgres function before issuing a JWT, and that function can inject custom claims. The claims become available in every subsequent request via auth.jwt().

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
  claims jsonb;
  user_tenant_id uuid;
  user_role text;
BEGIN
  -- The hook's "active tenant" can be a default (e.g., first membership)
  -- or pulled from a user_settings table where the user picked one.
  -- For simplicity here, pick the most recent membership.
  SELECT m.tenant_id, m.role
  INTO user_tenant_id, user_role
  FROM public.memberships m
  WHERE m.user_id = (event ->> 'user_id')::uuid
  ORDER BY m.created_at DESC
  LIMIT 1;

  claims := event -> 'claims';

  IF user_tenant_id IS NOT NULL THEN
    claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_tenant_id::text));
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  END IF;

  event := jsonb_set(event, '{claims}', claims);
  RETURN event;
END;
$$;

-- Grant execute to the auth hook role
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM authenticated, anon, public;

Then enable the hook in the Supabase dashboard under Authentication → Hooks (Beta) and select the function from the dropdown [2].

Three details that catch real implementations. The function is STABLE, not VOLATILE, because Supabase calls it during token issuance and a non-stable function adds noticeable login latency. The GRANT EXECUTE TO supabase_auth_admin and REVOKE FROM authenticated, anon, public pair matters: without the revoke, any authenticated client could call the function directly via PostgREST and learn the auth-admin internals. The function returns a modified event jsonb, not just the claims object; getting the shape wrong breaks token issuance with a generic "auth hook failed" error.

For "user belongs to multiple tenants, can switch active tenant" flows, the active tenant comes from somewhere persistent: a user_settings.active_tenant_id column, or a session cookie the app sets when the user picks a tenant from a dropdown. The hook reads from that source instead of "most recent membership."

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

RLS patterns: tenant isolation + role-based permissions

Now the policy layer. Every isolated table gets two policy types: read isolation (you can only see rows in your tenant) and write isolation with WITH CHECK (you can only insert/update rows tagged with your tenant) [1][4].

ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

-- SELECT: rows in your tenant only
CREATE POLICY "tenant_select"
ON public.projects
FOR SELECT
TO authenticated
USING (
  tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
);

-- INSERT: must tag the row with your tenant
CREATE POLICY "tenant_insert"
ON public.projects
FOR INSERT
TO authenticated
WITH CHECK (
  tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
);

-- UPDATE: row must be in your tenant; new tenant_id must also match
CREATE POLICY "tenant_update"
ON public.projects
FOR UPDATE
TO authenticated
USING (
  tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
)
WITH CHECK (
  tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
);

-- DELETE: row must be in your tenant
CREATE POLICY "tenant_delete"
ON public.projects
FOR DELETE
TO authenticated
USING (
  tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
);

The (SELECT auth.jwt()) form (with the subquery) is the Supabase-recommended perf pattern. Postgres evaluates the scalar subquery once per query instead of once per row, which matters when a single query touches thousands of rows.

For role-based permissions on top of tenant isolation, layer a second policy that checks the role claim:

-- Only admins and owners can delete projects
CREATE POLICY "role_admin_delete"
ON public.projects
FOR DELETE
TO authenticated
USING (
  tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
  AND ((SELECT auth.jwt()) ->> 'user_role') IN ('owner', 'admin')
);

Then drop the unconditional tenant_delete policy and keep only the role-gated one. Postgres treats multiple permissive policies as OR'd together by default, so to enforce role-AND-tenant, write the conjunction inside one policy rather than relying on separate policies that compose.

The RLS patterns guide covers the policy-debugging tooling and the EXPLAIN reading patterns for when a policy doesn't behave the way you expect.

How do you scope every Server Action to the current tenant?

The architectural rule from the backend-only data access pattern extends naturally: every Server Action reads the active tenant from the validated JWT, never from the request payload.

// actions/projects.ts
'use server'

import { z } from 'zod'
import { getUser, createAdminClient } from '@/lib/supabase/server'
import { createServerClientWithCookies } from '@/lib/supabase/server'

const createProjectSchema = z.object({
  name: z.string().min(1).max(120),
})

type AuthContext = {
  userId: string
  tenantId: string
  role: 'owner' | 'admin' | 'member'
}

// Read tenant + role from the validated JWT claims, never from payload.
async function requireTenantContext(): Promise<AuthContext | null> {
  const supabase = await createServerClientWithCookies()
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims
  if (!claims || !claims.email) return null
  const tenantId = claims.tenant_id as string | undefined
  const role = claims.user_role as 'owner' | 'admin' | 'member' | undefined
  if (!tenantId || !role) return null
  return { userId: claims.sub, tenantId, role }
}

export async function createProject(rawInput: unknown) {
  // 1. Validate the input shape.
  const parsed = createProjectSchema.safeParse(rawInput)
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors }
  }

  // 2. Read identity AND tenant from JWT claims.
  const ctx = await requireTenantContext()
  if (!ctx) return { error: 'Not authenticated' }

  // 3. The admin client bypasses RLS, but we stamp tenant_id from the
  //    validated context. The browser cannot lie about which tenant.
  const admin = createAdminClient()
  const { data, error } = await admin
    .from('projects')
    .insert({
      tenant_id: ctx.tenantId,
      created_by: ctx.userId,
      name: parsed.data.name,
    })
    .select()
    .single()

  if (error) {
    console.error('[createProject] insert failed:', error)
    return { error: 'Failed to create project' }
  }
  return { ok: true, project: data }
}

Three details matter. The requireTenantContext() helper centralizes the "read tenant from claims" pattern so no individual Server Action can forget it. The parsed.data from Zod does not include tenantId because the schema does not declare it; even if the client sends tenantId in the payload, it gets stripped by Zod's default object-stripping behavior. The admin client write uses ctx.tenantId from the validated context, not anything the client controlled.

The Server Actions + Zod guide covers the broader pattern; the tenant scoping is the multi-tenant-specific layer on top of the same architecture.

Performance: the composite index rule

The single biggest performance footgun in multi-tenant Postgres is missing composite indexes with tenant_id as the leading column. Benchmarks at 10K tenants and millions of rows show 120ms query time with sequential scans versus 1.2ms with the right composite index, a 100x difference [4].

The rule:

Every isolated table with an RLS policy needs tenant_id as the leading column in its primary access indexes.

Practical version:

-- For tables you query by recency within a tenant
CREATE INDEX idx_projects_tenant_recent ON public.projects(tenant_id, created_at DESC);

-- For tables you query by a foreign key within a tenant
CREATE INDEX idx_tasks_tenant_project ON public.tasks(tenant_id, project_id);

-- For tables you search by text within a tenant
CREATE INDEX idx_documents_tenant_title
ON public.documents(tenant_id, title text_pattern_ops);

What NOT to do: create a standalone index on tenant_id and a separate standalone index on created_at. Postgres' planner will sometimes pick the wrong one, sometimes do a bitmap-scan combination, and the RLS policy filter still has to apply on top. The composite index with tenant_id first lets Postgres seek directly to the tenant's slice of the table, then scan the secondary order within it.

EXPLAIN ANALYZE is the operator-side answer. A correctly indexed query against an RLS table looks like:

Index Scan using idx_projects_tenant_recent on projects
  Index Cond: (tenant_id = '...')
  Rows Removed by Filter: 0

Rows Removed by Filter of 0 means RLS evaluated against only rows that were already filtered by the index. Numbers above zero (especially with a Seq Scan) mean the planner is checking RLS against rows it shouldn't even see; the index is wrong.

For tables with hot subsets (active records vs archived, paid plans vs trials), partial indexes scoped to the hot subset are the next optimization. CREATE INDEX ... WHERE archived = false cuts index size dramatically for the queries that hit the working set.

Five multi-tenancy mistakes that ship cross-tenant data leaks

These are patterns that have caused real production breaches. Each one type-checks.

1. Reading tenant_id from the request payload.

A Server Action accepts { projectId, tenantId } in its input and uses both as query predicates. The client can submit any tenantId they want. RLS catches this on direct database access, but a Server Action using the admin client bypasses RLS and trusts the input. This is the canonical cross-tenant leak primitive. Always read tenant_id from getClaims(), never from the payload.

2. tenant_id column without a WITH CHECK clause.

A FOR INSERT TO authenticated USING (tenant_id = ...) policy without WITH CHECK allows the user to insert rows tagged with another tenant's ID. The USING clause governs SELECT and is misused on INSERT (where it doesn't apply); the WITH CHECK clause is what controls the values being written. Every INSERT and UPDATE policy on a tenant-isolated table needs both. The RLS policies guide has the full pattern.

3. JOIN-based queries that skip tenant_id on a joined table.

SELECT * FROM tasks JOIN projects ON tasks.project_id = projects.id WHERE projects.tenant_id = $1 looks correct, but if the tasks table also has tenant_id and an attacker can manipulate the join, they can read tasks from a foreign tenant attached to a project they own. Every table in a join chain that holds tenant-isolated data must have tenant_id in its WHERE clause OR an RLS policy that enforces it. The architectural answer: never write raw SQL across tenant boundaries; let RLS handle the isolation per-table and let the join compose the already-filtered sets.

4. Admin actions that don't re-scope to the target tenant.

A platform-admin Server Action that needs to act on tenant X's behalf reads the user's JWT, sees the user_role: 'super_admin' claim, and uses the admin client unscoped. Now the action operates without tenant filtering at all. Platform admin actions should still target a specific tenant ID (passed explicitly as part of the action signature, not implicit) and the action should set that tenant ID as the scope for the operation. Audit logs (mentioned in OWASP A09) become essential here because the normal RLS guards are intentionally bypassed.

5. Stale JWTs after a tenant switch.

User switches active tenant in the UI, but the JWT in the cookie still carries the old tenant_id claim until the next token refresh (up to an hour by default). A request issued during that window operates against the old tenant. The fix: force a token refresh via supabase.auth.refreshSession() immediately after a tenant switch, and clear any client-side caches keyed on tenant ID. The Supabase Auth in App Router guide covers the refresh patterns.

What this means for your Next.js + Supabase architecture

The architectural commitment that makes multi-tenancy safe is the same one that makes single-tenant Supabase apps safe: never trust the browser, always read identity from the validated session, layer RLS as defense in depth on top of application-layer scoping. The new pieces multi-tenancy adds (JWT claims for tenant + role, composite indexes leading with tenant_id, WITH CHECK on every write policy) are mechanical once the underlying pattern is in place.

SecureStartKit ships single-tenant primary because the brand frame's audience (solo developers building their first SaaS) usually starts there. Multi-tenancy is a real architectural commitment with operational consequences (more tables, more policies, more index discipline, a Custom Access Token Hook to maintain), so adding it before you need it costs without earning. The cluster 7.1 saas-template-comparison explicitly compares this trade-off against MakerKit and Supastarter, both of which lead with multi-tenant primitives.

When you do need multi-tenancy, the patterns above are the secure version. The architectural commitments from the 12-step hardening checklist, the OWASP A01 Broken Access Control defense, and the free Supabase RLS policy generator all carry over directly. The cross-tenant data leak is the highest-stakes bug class in B2B SaaS; the architecture above is what prevents it by default rather than catching it in code review.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Custom Claims & Role-based Access Control (RBAC)— supabase.com
  2. Custom Access Token Hook— supabase.com
  3. JWT Claims Reference— supabase.com
  4. Row Level Security— supabase.com
  5. OWASP Top 10:2025— owasp.org
  6. SupaPwn: Hacking Lovable and Helping Secure Supabase— hacktron.ai

Related Posts

May 17, 2026·Security

Supabase JWT + Session Management in Next.js [2026]

Supabase JWT lifecycle, ES256 asymmetric signing keys, httpOnly cookie storage, and getClaims vs getUser vs getSession for Next.js apps.

May 16, 2026·Security

OWASP Top 10:2025 for Next.js + Supabase Apps

OWASP Top 10:2025 mapped to Next.js + Supabase failure modes plus the architectural defenses that prevent each category. With 2026 CVEs.

May 21, 2026·Security

Supabase OAuth, Magic Links, MFA in Next.js [2026]

Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.