← All posts
·6 min read·GuardLayer

Supabase multi-tenant RLS: isolating tenant data

SupabaseRLSMulti-tenantPostgres

Multi-tenant isolation in Supabase breaks when an RLS policy filters on tenant_id but the subquery that produces those tenant ids is never scoped to the current user. The fix is one clause: where user_id = (select auth.uid()) inside the subquery, so the policy resolves to the tenants this caller belongs to.

Every multi-tenant Supabase app converges on the same shape: a tenant_id (or org_id, or workspace_id) column on every table, a memberships join table, and an RLS policy that says "you can see a row if you're a member of its tenant." That shape is correct. The way it's usually written is not.

Why does my Supabase RLS policy return other tenants' rows?

Because the subquery inside USING isn't filtered by the current user, so it evaluates to every tenant id in the table rather than the caller's. Here's the policy almost everyone writes first:

create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  amount_cents integer not null
);

alter table public.invoices enable row level security;

-- Only members of the tenant should see its invoices.
create policy "members can read invoices"
  on public.invoices
  for select
  using (
    tenant_id in (select tenant_id from public.memberships)
  );

Read that USING clause as Postgres does. select tenant_id from public.memberships has no WHERE. It returns every tenant id that appears anywhere in the memberships table — all of them, for all customers. So the predicate becomes "tenant_id is in the set of all tenant ids," which is true for every row in invoices.

RLS is on. There's a policy. There's a tenant_id filter. And every logged-in user can read every tenant's invoices.

guardlayer scan · supabase/migrations/20260803120000_invoices.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20260803120000_invoices.sql:10

    RLS policy without user scoping

    Scope the policy to the requesting user/role, e.g. USING (auth.uid() = user_id). If the data is intentionally public, make that explicit and document it.

This is the same failure as an RLS policy that never references auth.uid() — it just hides better, because the subquery looks like scoping. A reviewer skims it, sees memberships, and moves on.

The isolation pattern that holds

Anchor the subquery to the caller:

drop policy "members can read invoices" on public.invoices;

create policy "members can read invoices"
  on public.invoices
  for select
  to authenticated
  using (
    tenant_id in (
      select tenant_id
      from public.memberships
      where user_id = (select auth.uid())
    )
  );

Three things changed, and all three matter:

  • where user_id = (select auth.uid()) is the actual fix. The subquery now returns only the tenants this caller belongs to.
  • (select auth.uid()) rather than a bare auth.uid(). Postgres hoists the scalar subquery into an InitPlan and evaluates it once per query instead of once per row — the single largest win in RLS query performance.
  • to authenticated stops the policy from being evaluated for anonymous callers at all.

Then repeat for writes. A SELECT policy alone doesn't stop anyone from inserting a row into someone else's tenant:

create policy "members can insert invoices"
  on public.invoices
  for insert
  to authenticated
  with check (
    tenant_id in (
      select tenant_id
      from public.memberships
      where user_id = (select auth.uid())
    )
  );

USING filters rows that already exist (SELECT, UPDATE, DELETE). WITH CHECK validates rows being written (INSERT, UPDATE). Ship only the first and your tenant boundary is read-only — a user can still forge invoices into any tenant they can name.

Don't forget create index on public.memberships (user_id, tenant_id);. That subquery now runs on every policy evaluation.

The JWT shortcut, and its expiry problem

The membership-subquery pattern costs a join per query. The faster alternative is to put the tenant id straight in the token and read it with auth.jwt():

using (tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid)

If you do this, the claim must live in app_metadata, never user_metadata. Supabase's own docs draw that line explicitly:

raw_user_meta_data can be updated by the authenticated user using the supabase.auth.update() function and is not a good place to store authorization data. raw_app_meta_data cannot be updated by the user, so it's a good place to store authorization data.

Supabase docs, Row Level Security

Put tenant_id in user_metadata and any user can move themselves into your biggest customer's tenant with one SDK call. That distinction is worth understanding fully before you rely on JWT claims — see app_metadata vs user_metadata in RLS.

The second caveat is freshness. The same docs note:

Keep in mind that a JWT is not always fresh.

Remove someone from a tenant and their existing token still carries the old claim until it refreshes. For a membership change that must take effect immediately — an offboarding, a revoked contractor — the subquery pattern is the safer default, because it reads live table state on every query.

Permissive policies stack with OR

The last multi-tenant footgun is additive. Postgres policies are permissive by default, and multiple permissive policies on the same table combine with OR. Add a well-scoped tenant policy, then later add a convenience policy for a dashboard, and access is the union of the two — the broader one wins.

That's how a table ends up with a perfect tenant policy sitting next to a USING (true) policy that quietly overrides it. When you need a condition that must hold no matter what other policies say, make it as restrictive — restrictive policies combine with AND.

A 60-second self-check

# 1. Policy subqueries with no WHERE clause — the leak in this post
grep -rn "in (select" supabase/migrations/ | grep -iv "where"

# 2. Any policy reading a role or tenant out of user_metadata
grep -rn "user_metadata" supabase/migrations/

# 3. Tables with a tenant column but no INSERT/UPDATE policy
grep -rln "tenant_id\|org_id" supabase/migrations/ | xargs grep -Ln "with check"

Then verify from the client, not the SQL editor — the editor runs as a superuser role and bypasses RLS entirely, so a broken tenant policy looks fine there. Sign in as a user in tenant A, query the table, and confirm you get zero rows from tenant B.

GuardLayer's supabase/policy-no-user-scope rule flags policy predicates that reference none of auth.uid(), auth.jwt(), or auth.role() — which is exactly the shape of the unscoped subquery above — on every push.

FAQ

Should I use a tenant_id column or a schema per tenant? A tenant_id column with RLS for almost every SaaS. Schema-per-tenant multiplies migration work by your customer count and doesn't use RLS at all. Reach for it only under a hard compliance requirement for physical separation.

Is tenant_id in the JWT safe? Yes, if it's in app_metadata — users can't modify that. It's unsafe in user_metadata, which users can update themselves. The trade-off is staleness: JWT claims lag membership changes until the token refreshes.

Why does my policy work in the SQL editor but not from the app? The SQL editor bypasses RLS, so it proves nothing about your policies. Test with the client SDK signed in as a real user.

Do I need a policy for every operation? Yes. A for select policy doesn't govern inserts. Write explicit INSERT, UPDATE, and DELETE policies with WITH CHECK predicates, or those operations have no tenant boundary.

Why is my multi-tenant query suddenly slow? Usually an unindexed memberships lookup, or a bare auth.uid() re-evaluated per row. Add the composite index and wrap the call as (select auth.uid()).

Catch this before it ships — free

GuardLayer scans every push for this and 26 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading