A concerned startup founder reviews a tablet with a blurred, alarming data visualization, highlighting a critical multi tenant data bleed.
Production engineering patternUpdated

Multi-Tenant Supabase + Postgres RLS: Stop Tenant Data Bleed

An engineering pattern for keeping every tenant's data invisible to every other tenant — at the database, not at the query layer where one missing WHERE clause leaks the entire table.

The problem

The failure mode that ends multi-tenant SaaS companies is tenant bleed. Customer A logs in, opens the dashboard, and sees a list of records belonging to Customer B. The data is correct in the database — Customer B owns those rows — but the query that built the page forgot to filter by tenant. The screenshot lands in a support ticket within an hour. If you are lucky, it lands in a support ticket. If you are unlucky, the customer takes it to LinkedIn and your sales pipeline freezes for a quarter while every prospect's security team asks for a written explanation.

One missing WHERE clause from a breach

The root cause is almost the same shape every time. An engineer writes a query like select * from invoices where status = 'open', the test passes against the single-tenant fixture, the code ships, and weeks later someone runs it from a session that crosses a tenant boundary. The query is correct as written. The bug is that the query was ever allowed to omit the tenant filter at all. In a system where every query is one missing WHERE tenant_id = $1 away from a breach, you do not have isolation — you have a convention, and conventions decay the second a new engineer joins or an existing one is rushed.

Query-helper isolation is honor-system

The reflex fix is to wrap every query in a helper that appends the tenant clause for you. That works until someone writes a raw query, or uses an ORM escape hatch, or builds a report against the database directly, or calls an internal admin endpoint with the wrong client. The helper is honor-system. The database is not.

The trust tax after a leak

The business cost of tenant bleed is more than one angry customer. Every SOC 2 audit you do for the next three years cites the incident. Every enterprise prospect's security review takes an extra month. Every internal feature ship slows down because reviewers now ask "are you sure this is tenant-safe" on every PR. The cleanup is not the support ticket — it is the trust tax you pay until you can show structurally that the bug cannot happen again.

The pattern below moves isolation from the query layer into the database, where the rules are enforced by Postgres regardless of which engineer wrote which query.

Two engineers collaborate intently at a whiteboard with abstract shapes, designing robust multi tenant database isolation policies.

What changes for your business

The architecture has four pieces that have to hold together: a tenant_id column on every multi-tenant table, RLS enabled AND forced on those tables, a policy that reads tenant identity from the verified JWT, and a service_role boundary that no tenant request can reach. Each piece protects against a specific failure of the others.

Non-null tenant_id column on every table

Start with the schema. Every table that holds tenant data carries a tenant_id uuid not null column. Resist the temptation to make it nullable or default-able. A nullable tenant_id means a row can exist without an owner, and a row without an owner is one foreign key cascade away from being readable by everyone.

CREATE TABLE invoices (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
  amount      numeric(12, 2) NOT NULL,
  status      text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- The single index that makes every policy check fast.
CREATE INDEX invoices_tenant_id_idx ON invoices (tenant_id);

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- The line teams forget. Without FORCE, the table owner — which is
-- the role your migrations run as — bypasses RLS entirely.
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

The Postgres documentation is explicit on why both ENABLE and FORCE are required: superusers and roles with the BYPASSRLS attribute skip RLS by definition, and table owners normally skip RLS as well unless they opt in with FORCE ROW LEVEL SECURITY. If your migration tool connects as the role that created the table, that role is the owner. A code path that uses the migration credentials — a back-office report, a data export job, an "admin" script — sees every row in the table even though policies are enabled. FORCE closes that gap.

Policy reads tenant_id from app_metadata

Now the policy. Tenant identity lives in the JWT, in the app_metadata claim. App_metadata is the right home because Supabase controls writes to it; user_metadata is user-writable and therefore unsafe to read for authorization decisions. Stamp the tenant_id into app_metadata at signup or invite, and the policy can trust it.

-- Read tenant_id from the verified JWT, not from user_metadata.
CREATE POLICY tenant_isolation_select ON invoices
  FOR SELECT
  USING (
    tenant_id = ((select auth.jwt() -> 'app_metadata' ->> 'tenant_id'))::uuid
  );

CREATE POLICY tenant_isolation_modify ON invoices
  FOR ALL
  USING (
    tenant_id = ((select auth.jwt() -> 'app_metadata' ->> 'tenant_id'))::uuid
  )
  WITH CHECK (
    tenant_id = ((select auth.jwt() -> 'app_metadata' ->> 'tenant_id'))::uuid
  );

USING and WITH CHECK on every write policy

Two policy details matter here. First, the USING and WITH CHECK expressions are independent for a reason: USING controls which existing rows are visible (and therefore which rows DELETE and the visibility side of UPDATE can touch), while WITH CHECK controls which rows are permitted by INSERT and the write side of UPDATE. Set both, set them to the same expression, and a tenant can neither read another tenant's row nor write a row tagged with another tenant's id.

Subquery-wrapped auth helper for initPlan caching

Second, the (select auth.jwt() ...) wrapper is not stylistic. Supabase documents that wrapping the auth helper in a subquery causes Postgres to run an initPlan once per statement and cache the result, instead of re-evaluating the function for every candidate row. Without the wrapper, a query against a million-row table calls auth.jwt() a million times. With the wrapper, it calls it once.

Read path through PostgREST claims handoff

Now the read path. The diagram below traces a single select * from invoices from the client to the returned rows, with the points where the policy and the JWT enter the picture marked. Read it as a checklist: every arrow is a place the isolation can be wrong if you do not configure it correctly.

┌────────────┐    1. Authed request with JWT in Authorization header
│   Client   │ ─────────────────────────────────────────────────────┐
└────────────┘                                                       │
                                                                     ▼
                                                          ┌────────────────────┐
                                                          │   PostgREST (or    │
                                                          │   your API layer)  │
                                                          └──────────┬─────────┘
                                                                     │ 2. SET LOCAL request.jwt.claims = '<jwt>'
                                                                     │    SET LOCAL role = 'authenticated'
                                                                     ▼
                                                          ┌────────────────────┐
                                                          │      Postgres      │
                                                          │  ┌──────────────┐  │
       4. Result rows  ◄──────────────────────────────────│  │  RLS policy  │◄─┤ 3. Plan SELECT;
       (filtered to caller's tenant)                      │  │  uses        │  │    policy injected
                                                          │  │  auth.jwt()  │  │    as implicit WHERE
                                                          │  └──────────────┘  │
                                                          └────────────────────┘

The four steps are the whole isolation surface. Step 1 is HTTPS — out of scope for the database, but a stolen JWT is a stolen tenant identity, so token lifetime and rotation matter. Step 2 is where your API hands the JWT to Postgres as a session variable; Supabase's PostgREST does this for you, and if you are building a custom backend you have to do it yourself with SET LOCAL request.jwt.claims. Step 3 is the policy attaching to the query plan. Step 4 is the filtered result. Each step is independent — get any of them wrong and the isolation chain breaks.

A confident CTO observes a clean, abstract security dashboard on a large monitor, reflecting robust multi tenant supabase RLS.

More on this

Integration tests that exercise the policy

The test that matters is the one that runs cross-tenant SELECTs and asserts they return zero rows. Most teams write the test that says "tenant A can read tenant A's rows," see it pass, and stop there. That test passes whether or not the policy works, because tenant A's query against tenant A's data succeeds in both worlds. The real test is the negative case.

import { createClient } from "@supabase/supabase-js";
import { describe, expect, it, beforeAll } from "vitest";

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!;

describe("invoices RLS: tenant isolation", () => {
  let tenantAClient: ReturnType<typeof createClient>;
  let tenantBClient: ReturnType<typeof createClient>;
  let invoiceAId: string;
  let invoiceBId: string;

  beforeAll(async () => {
    // Two pre-seeded users, each in a different tenant.
    tenantAClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
    tenantBClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

    await tenantAClient.auth.signInWithPassword({
      email: "alice@tenant-a.test",
      password: process.env.TEST_PW_A!,
    });
    await tenantBClient.auth.signInWithPassword({
      email: "bob@tenant-b.test",
      password: process.env.TEST_PW_B!,
    });

    // Each user inserts a row in their own tenant.
    const a = await tenantAClient
      .from("invoices")
      .insert({ amount: 100, status: "open" })
      .select("id")
      .single();
    const b = await tenantBClient
      .from("invoices")
      .insert({ amount: 200, status: "open" })
      .select("id")
      .single();

    invoiceAId = a.data!.id;
    invoiceBId = b.data!.id;
  });

  it("tenant A cannot read tenant B's invoice by id", async () => {
    const { data, error } = await tenantAClient
      .from("invoices")
      .select("*")
      .eq("id", invoiceBId);

    expect(error).toBeNull();
    // The interesting assertion: zero rows, not a forbidden error.
    // RLS filters silently — the row is invisible, not denied.
    expect(data).toEqual([]);
  });

  it("tenant A cannot list tenant B's invoices via select *", async () => {
    const { data } = await tenantAClient.from("invoices").select("id");
    const ids = (data ?? []).map((r) => r.id);
    expect(ids).toContain(invoiceAId);
    expect(ids).not.toContain(invoiceBId);
  });

  it("tenant A cannot insert a row tagged with tenant B's id", async () => {
    // Even if tenant A constructs the request, the WITH CHECK clause
    // rejects the write because the tenant_id does not match the JWT.
    const tenantBId = await getTenantIdFor("bob@tenant-b.test");
    const { error } = await tenantAClient
      .from("invoices")
      .insert({ amount: 999, status: "open", tenant_id: tenantBId });

    expect(error).not.toBeNull();
  });
});

Three properties make this test useful where a naive happy-path test is not. It runs through the same client library and the same auth flow that the real application uses, so any divergence between policy and runtime shows up here. It asserts the silent-filter behavior (zero rows, no error) — which is how RLS actually works and what real attacker probes look like. And it covers both read and write paths, because a policy that protects SELECT but not INSERT lets a tenant plant rows under another tenant's identity.

Wire the test into CI so every PR that touches a multi-tenant table runs it. The test is fast — single-digit seconds — and the cost of skipping it is the cost of the bug it catches.

Common failure modes

The first sharp edge is the developer who reaches for the service_role key when something does not work. The service_role bypasses RLS by design so admin tooling can read across tenants for back-office reports and cleanup scripts. The Supabase docs are explicit: it is for administrative tasks and should not be exposed in the browser. The failure mode is more subtle than "the key leaks." It is a backend handler that uses the service_role client to do something convenient — load a dropdown, populate an export, run a search — and now that handler has god-mode against every tenant. The fix is to keep the service_role import inside a small set of files that the build pipeline audits, and ban it everywhere else with a lint rule. If a code review finds createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) outside the audited directory, that PR does not merge.

The second is the missing FORCE. The migration enables RLS, the policies exist, the engineer tests by running the app and seeing isolation work — because the app connects as the authenticated role, not the table owner. Then a background job, written months later, connects as the migration role to do something housekeeping-related, and that job sees every row in every tenant. The Postgres documentation states it plainly: table owners normally bypass row security as well. The fix is to add ALTER TABLE ... FORCE ROW LEVEL SECURITY immediately after ENABLE, and to add a one-line audit query to CI that fails the build if any table is enabled-but-not-forced.

The third is the user_metadata versus app_metadata mix-up. A policy that reads auth.jwt() -> 'user_metadata' ->> 'tenant_id' looks correct and works in development. In production, a tenant who knows their JWT structure can update their own user_metadata through the Supabase client and rewrite the tenant_id claim. The policy then trusts their forged claim. App_metadata is the right home because Supabase controls writes to it; user_metadata is user-writable. The fix is to stamp tenant_id into app_metadata at signup or invite (via the admin API or a server-side hook) and to read it via app_metadata in every policy.

The fourth is the policy that protects SELECT but forgets to protect INSERT. The reader cannot see another tenant's data, but a malicious request can plant a row tagged with another tenant's id. On the next read, that row appears in the wrong tenant's dashboard — not because of a leak on the read side, but because a write was allowed that should have been rejected. The fix is to set the WITH CHECK clause on every policy that covers a write operation, and to write the cross-tenant INSERT test in the integration suite above.

The fifth is the JOIN that bypasses the policy. A table without RLS joined to a table with RLS leaks through the unprotected table. Policies apply per-table; if invoices is RLS-isolated but invoice_line_items is not, a SELECT with a JOIN through invoice_line_items can return rows that belong to another tenant. The fix is to enable RLS on every table in the multi-tenant set, and to audit with SELECT relname FROM pg_class WHERE relrowsecurity = false AND relkind = 'r' in a CI step that filters down to tables in your tenant schema.

What to watch in your own implementation

Open the database and run this against every public schema table that holds tenant data:

SELECT
  c.relname AS table_name,
  c.relrowsecurity AS rls_enabled,
  c.relforcerowsecurity AS rls_forced,
  (
    SELECT COUNT(*)
    FROM pg_policies p
    WHERE p.schemaname = n.nspname AND p.tablename = c.relname
  ) AS policy_count
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
  AND c.relkind = 'r'
ORDER BY c.relname;

The query gives you three columns to inspect. Tables with rls_enabled = false are wide open. Tables with rls_enabled = true, rls_forced = false are wide open to your own background jobs and migrations. Tables with policy_count = 0 and RLS enabled are unreachable (the default-deny behavior takes over — no rows visible to anyone except superusers and bypass roles). Any row that is not "enabled, forced, with policies" is a misconfiguration. Fix those before anything else.

Next, audit the application code for service_role usage. Search the repo for SERVICE_ROLE_KEY and trace every caller. For each caller, answer two questions. Does it run only in a server context that no tenant request can reach? And is the directory it lives in covered by a lint rule that prevents new callers from being added elsewhere? If the answer to either is no, the isolation is one accidental import away from being bypassed.

Then look at the JWT shape. Pull a real JWT from a production session, decode it, and check whether tenant_id lives in app_metadata or user_metadata. If it lives in user_metadata, your policies are trusting a value the tenant can edit. Move it to app_metadata, update the signup flow to stamp it from the server side, and update every policy that reads it.

Finally, write the cross-tenant SELECT integration test if you do not already have one. Two seeded tenants, one row in each, a cross-tenant query, an assertion that the row count is zero. The test takes an hour to write and runs in seconds. It is the single test that proves the policy is doing the thing the diagram says it does, and it is the test that catches the regression when someone adds a new table and forgets to enable RLS.

The one operational discipline worth adding is a quarterly review of new tables. Every multi-tenant SaaS schema grows. The discipline is: when a new table lands in the multi-tenant set, the PR that adds it also adds the tenant_id column, the FORCE RLS lines, the policy, the index on tenant_id, and a row in the cross-tenant integration test. Encode that as a checklist in the PR template, and the policy file stops drifting from the schema.

What this looks like in production

At BFEAI the tenant_id flows from the JWT through every read and write, and the policy file is the file we audit most often when adding a feature. New tables ship with RLS enabled, FORCE on, and a policy that references the same app_metadata ->> 'tenant_id' helper as every other table in the schema. The integration suite runs the cross-tenant SELECT and the cross-tenant INSERT test on every PR, and the audit query above runs in a scheduled CI job that pages if any table drifts out of the enforced-and-forced state. The result is that "tenant bleed" is not a class of bug the team has to remember to prevent in each query — it is a class of bug the database refuses to allow, and the application code is free to be written without the constant fear that one missing WHERE tenant_id = $1 will end the company.

Outcomes you should expect

What this delivers

  • A SELECT * with the wrong tenant's JWT returns zero rows instead of leaking data — verified by an integration test that runs on every CI build.
  • Backend code that previously had to remember WHERE tenant_id = $1 becomes safe to forget, because the database refuses to return rows the caller cannot see.
  • A misuse of the service_role key is contained to server contexts the build pipeline can audit, not scattered through frontend code where it would silently disable isolation.
  • Onboarding a new engineer is a half-day instead of a week, because the isolation rules live in one place — the policy file — instead of being a tribal rule about how to write queries.

Primary sources

By the numbers

  • If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified.

    Source ↗

  • Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table. Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY.

    Source ↗

  • The USING expression and the WITH CHECK expression of a policy provide independent control over the rows which are visible and the rows which are allowed to be modified.

    Source ↗

  • Supabase will adhere to the RLS policy of the signed-in user, even if the client library is initialized with a Service Key; the service role is intended for administrative tasks and should never be exposed in the browser.

    Source ↗

  • Wrapping auth helpers in a subquery — for example (select auth.uid()) — lets the Postgres optimizer run an initPlan that caches the result per-statement instead of re-evaluating the function on every row.

    Source ↗

  • auth.jwt() returns the JWT of the user making the request, and Supabase recommends reading authorization data from raw_app_meta_data because raw_user_meta_data is user-writable and therefore unsafe for policy decisions.

    Source ↗

Live in production today

The same engineering, shipped in production at BFEAI.

I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.

7

Production apps

200K+

Keywords generated

1,500+

AI scans run

7,000+

Sites automated

Common questions

What buyers ask before reaching out

Why is enabling RLS not enough on its own?

Enabling RLS turns on policy enforcement for non-owner roles, but the role that created the table is normally exempt. If your migration runs as a role that owns the table — which is the default in most Supabase projects — policies will not apply to queries from that role unless you also issue ALTER TABLE ... FORCE ROW LEVEL SECURITY. Without FORCE, a code path that connects with the owner credentials sees every row regardless of policy.

What is the service_role key and why is it dangerous in multi-tenant code?

The service_role key is a Supabase-issued credential that bypasses RLS entirely so admin tasks like back-office reports and migrations can read across tenants. The danger is using it in any code path that handles tenant-supplied input. If a request handler grabs the service_role client to do something convenient — populating a dropdown, deleting an old record — that handler now has god-mode against every tenant in the database, and the policy file no longer protects you.

Can I trust tenant_id in the JWT, or does it have to be checked against the database?

You can trust claims in raw_app_meta_data because Supabase Auth controls that field and surfaces it through auth.jwt(). You should not trust anything in raw_user_meta_data because that field is user-writable; a tenant could otherwise edit their own JWT claims and switch tenant identity. The safe pattern is: stamp tenant_id into raw_app_meta_data at signup or invite, then read it via (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid in policies.

How do I write an integration test that actually exercises the policy?

Connect to Postgres as a non-superuser role with the JWT claims set on the connection — Supabase exposes this through the request.jwt.claims session variable. Then run the same query a user would run and assert the row count. If you run the test as the service role or as the table owner, the test passes for the wrong reason because RLS does not apply to either. A real test creates two tenants, inserts a row into each, switches JWT context, and asserts the cross-tenant SELECT returns zero rows.

Should policies use auth.uid() or auth.jwt() for tenant isolation?

Use auth.jwt() when isolation is by tenant_id, because the tenant claim lives in the JWT and one user can belong to multiple tenants over a session. Use auth.uid() when isolation is by individual user — for example, a notes table where each user only sees their own rows. Most multi-tenant SaaS schemas need both: tenant_id-based policies on shared tables, and an additional auth.uid() check on user-private rows inside that tenant.

What happens to policy performance as the row count grows?

The policy expression runs once per candidate row, so the cost lives in two places: the function call inside the policy, and the index on the tenant_id column. Supabase documents wrapping auth helpers in a SELECT subquery so Postgres runs an initPlan once per statement instead of once per row. Pair that with a btree index on tenant_id and you typically see policy overhead disappear into the query plan rather than dominate it.

Do I still need application-layer tenant checks if RLS is on?

RLS is the floor, not the ceiling. Application-layer checks catch the cases where you are doing something RLS cannot see — for example, calling out to a third-party API with one tenant's data and getting back a response that should be written to a different tenant. RLS protects the database. The application layer protects the boundaries around the database. Both belong in a defense-in-depth setup.

How do I migrate an existing single-tenant schema to RLS-isolated multi-tenant?

Add a nullable tenant_id column to every affected table, backfill it from a single bootstrap tenant for existing rows, make it NOT NULL, then enable and FORCE RLS with the tenant_id policy. Do it one table at a time with the audit query in place so you can see drift between application reads and policy-permitted reads. The migration is mechanical once the first table is done — most of the work is the audit harness, not the schema change.

What if my reporting layer needs to read across tenants?

Run reports through a dedicated role with BYPASSRLS, not through the service_role key in application code. The BYPASSRLS role lives behind your data warehouse credentials or behind a backend job runner that no tenant request can reach. Reporting reads are the legitimate use case for crossing tenant boundaries, and isolating them to a dedicated role keeps the audit story clean: any cross-tenant read is one of N known jobs, not an unknown handler that picked up the wrong client.

Ready to see if this is a fit?

A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.