Skip to content
Tech Stack2 June 2026 · 7 min readUpdated 30 September 2026

Multi-Tenant SaaS Architecture with Supabase RLS: The 2026 Playbook

How to isolate tenants in Supabase with Postgres Row-Level Security: the tenant model, read and insert policies, the recursion trap, indexes and a safe migration order.

Multi-Tenant SaaS Architecture with Supabase RLS: The 2026 Playbook

If you're building a multi-tenant SaaS on Supabase, the workable pattern is a workspaces table, a memberships table that maps users to workspaces with a role, and a workspace_id column on every data table, with Row-Level Security policies reading that membership. Start there today: add the two tables and the column before you write a single policy. The Supabase quickstart shows one table and one policy; production adds roles, joins and write rules, and this post covers those parts.

A note on provenance. Callidus, the UK aesthetic-clinic SaaS I built, runs on Firebase with Firestore rules, not on Supabase or Postgres RLS. I use it as my multi-tenant production reference, and the comparison lives in Firestore Rules vs Postgres RLS for Multi-Tenant SaaS. The SQL below is illustrative and checked against the docs I cite, not extracted from Callidus. For the wider picture, start with the Multi-Tenant SaaS Architecture guide.

How should you model tenants before writing RLS policies?

Model three things first: a tenants table with a UUID key, a memberships table keyed on user and workspace with a role, and a non-nullable, indexed workspace_id on every data table. RLS policies are SQL predicates, so they can only filter on columns that already exist in a consistent shape.

Concentric ring access pattern: nested geometric rings suggesting role hierarchy radiating from a center, dark background, neon cyan glow on outer ring

Most teams reach for Row-Level Security before the tenant model is settled, and then patch policies around schema gaps. Name the table workspaces, tenants or organizations; the name doesn't matter, consistency does. A missing workspace_id on even one table is a policy gap you'll find at the worst time. Define the role as a check constraint so invalid values never reach application code:

CREATE TABLE memberships (
  user_id      uuid REFERENCES auth.users NOT NULL,
  workspace_id uuid REFERENCES workspaces NOT NULL,
  role         text CHECK (role IN ('owner', 'admin', 'member', 'viewer')) NOT NULL,
  PRIMARY KEY  (user_id, workspace_id)
);

Supabase Auth exposes the signed-in user's ID through auth.uid(), and every policy compares membership rows against it. The React + Supabase RLS stack page shows how this fits a frontend. If you're weighing this shared-schema model against separate databases, read Database-per-Tenant vs Shared Schema for SaaS, and if you sell to companies, the glossary entries on multi-tenant SaaS and B2B SaaS define the terms.

Is application-layer access control defense in depth?

No. Checks that live only in application code are one layer that a bug, a misconfigured middleware chain, an admin script or a direct database connection can skip, while RLS is evaluated by Postgres itself. My view is that calling app-only checks "defense in depth" lets a team ship without the real RLS work.

The stakes are documented. OWASP's 2025 Top 10 ranks Broken Access Control first and reports that 100% of the applications tested had some form of it. Its prevention advice includes denying by default, which is exactly what Postgres does. The PostgreSQL docs say that if no policy exists for a table with row security enabled, a default-deny policy applies and no rows are visible or modifiable.

RLS has its own bypasses, and you should know them. Per the same Postgres page, superusers and roles with BYPASSRLS always bypass it, table owners normally bypass it unless FORCE ROW LEVEL SECURITY is set, and referential integrity checks bypass it, which can open "covert channel" leaks if you aren't careful. On the Supabase side, the RLS guide says the secret key bypasses RLS when the request carries no user token, and that it must never reach browsers. A table in an exposed schema without RLS is readable and writable by any role with a grant on it.

How do you write policies without recursion or weak write rules?

Write one policy per command, name the role with TO authenticated, and keep membership lookups out of self-referencing subqueries. The Postgres docs explain that USING decides which existing rows are visible, while WITH CHECK decides which new rows an INSERT or UPDATE may produce.

What does a clean read policy look like?

Supabase's guide recommends wrapping functions in a select so the result can be cached, and naming roles with to. A read policy for a projects table:

CREATE POLICY "members read projects"
ON projects FOR SELECT
TO authenticated
USING (
  workspace_id IN (
    SELECT workspace_id FROM memberships
    WHERE user_id = (SELECT auth.uid())
  )
);

The trap sits in that subquery. If memberships has its own policy that queries memberships, the policy refers to itself. Supabase's guide recommends security definer functions to break recursive policy cycles:

CREATE OR REPLACE FUNCTION public.current_user_workspace_ids()
RETURNS SETOF uuid
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = ''
AS $$
  SELECT workspace_id FROM public.memberships
  WHERE user_id = (SELECT auth.uid())
$$;

Two details come from the CREATE FUNCTION docs. A security definer function runs with its owner's privileges, so set search_path to exclude schemas writable by untrusted users (an empty path with qualified names is the simplest way). And execute privilege goes to PUBLIC by default, so revoke it and grant it selectively in the same transaction. The function is only needed if the memberships policy itself queries memberships; a plain user_id = (SELECT auth.uid()) check doesn't loop.

Where does WITH CHECK matter?

It matters on INSERT, where it's the only expression a policy can have, and on UPDATE when the rule for the new row should differ from the rule for the old one. The Postgres docs say that when an UPDATE or ALL policy has no WITH CHECK, the USING expression is reused for both checks. So a missing clause is not the silent hole it's often described as, but an explicit one keeps intent readable:

CREATE POLICY "editors insert projects"
ON projects FOR INSERT
TO authenticated
WITH CHECK (
  EXISTS (
    SELECT 1 FROM memberships m
    WHERE m.user_id = (SELECT auth.uid())
      AND m.workspace_id = projects.workspace_id
      AND m.role IN ('owner', 'admin', 'member')
  )
);

What keeps Supabase RLS fast and safe in production?

Index every column your policies filter on, keep authorization data out of user-editable fields, and migrate in an order that never exposes or locks out a table. Each item below comes from the Supabase or Postgres docs unless I label it as my recommendation.

  • Indexes. Supabase says to add indexes on every column your policies filter on. Here that means workspace_id on each data table and (user_id, workspace_id) on memberships.
  • Claims. If a policy reads the JWT, Supabase warns that user_metadata can be modified by authenticated end users and points to app_metadata, which users can't update. A role stored in user_metadata is a role the user can grant themselves.
  • Unauthenticated calls. auth.uid() returns null without a signed-in user, so a comparison silently matches nothing. Scope policies with TO authenticated and test the logged-out case.
  • Pooler. Supabase documents that transaction mode on port 6543 doesn't support prepared statements, and tells you to turn them off in your connection library.

What is a safe migration order?

For converting a single-tenant app, this is the order I recommend:

  1. Add workspace_id to each data table and backfill it with a seed workspace.
  2. Create workspaces and memberships, and make existing users owners of the seed workspace.
  3. Write the read, insert, update and delete policies for each table.
  4. Run ALTER TABLE ... ENABLE ROW LEVEL SECURITY in the same transaction as the policies, since an enabled table with no policy returns no rows.
  5. Confirm no browser code holds the secret key.
  6. Create a second workspace and try to read the first one's rows as its user.

Run the last check with two real test users rather than the secret key, because that key bypasses the very rules you're testing, and repeat it whenever you add a table.

Do you need an audit log and a support tool?

Both are my recommendations, not platform requirements. An append-only audit_log table written by triggers gives you a record that application bugs can't skip, and giving it no UPDATE or DELETE policy makes Postgres deny those by default, though owners bypass RLS unless you force it. For support, avoid pulling the secret key to inspect a customer's data; instead, read through a dedicated support role that has its own membership rows, so you debug inside the same boundary your customers sit in.

Next action: open your largest table, check that it has a workspace_id index, and write down which of its four commands still lack a policy.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is Supabase Row-Level Security and how does it isolate tenants?

Supabase Row-Level Security is the Postgres row security feature applied to your tables, and it isolates tenants by attaching a policy to each table. Once RLS is enabled, a query only sees rows its policies allow, and with no policy the Postgres docs say a default-deny policy applies. For multi-tenant SaaS you write a policy comparing each row's workspace_id to the signed-in user's memberships. Superusers, BYPASSRLS roles, table owners and the Supabase secret key can bypass it, so keep those out of client code.

How do you model multi-tenant data in Postgres with RLS?

Model a workspaces table, a memberships table keyed on user and workspace with a role, and a non-nullable workspace_id on every data table. Policies can only filter on columns that exist in a consistent shape, so settle this schema before writing them. Add the column, its index and the policies in the initial migration rather than as an afterthought, and constrain the role column with a check so invalid values are rejected by the database before they reach application code.

What is the recursive policy problem in Supabase RLS?

The recursive policy problem appears when a policy on memberships queries memberships, so the policy refers to itself. Supabase's RLS guide recommends security definer functions to break such cycles. A function that returns the current user's workspace IDs runs with its owner's privileges, so set an explicit search_path, revoke the default PUBLIC execute privilege and grant it selectively, as the Postgres CREATE FUNCTION docs advise. You only need the function when the memberships policy itself contains a self-referencing subquery.

What Postgres indexes do you need for RLS performance?

Index every column your policies filter on, which Supabase's guidance states directly. In the tenant model on this page that means workspace_id on each data table and a composite index on (user_id, workspace_id) on memberships, since the membership lookup runs inside policy checks. Supabase also advises wrapping function calls such as auth.uid() in a select so the result can be cached, and naming roles with the to clause so policies don't run for roles they don't apply to.

Does WITH CHECK matter on every policy?

WITH CHECK matters on INSERT policies, where it is the only expression allowed, and on UPDATE policies whose rule for new rows differs from the rule for old rows. The Postgres documentation says that when an UPDATE or ALL policy has no WITH CHECK, the USING expression is used for both visibility and the new-row check. That means a missing clause is not a silent hole by itself, but writing it explicitly makes the intended write rule reviewable, which helps when roles differ in what they may change.