Multi-tenant SaaS with Supabase row-level security: lessons learned

How to build multi-tenant SaaS with Supabase RLS: tenant keys, membership tables, permission checks in Postgres and testing isolation properly.

Published
Reading time
9 minutes
Topic
Engineering
Author
Hamza Naqvi
Hamza Naqvi, illustration for Multi-tenant SaaS with Supabase row-level security: lessons learned
Short answer

To build multi-tenant SaaS on Supabase, put a tenant ID on every tenant-owned table, store users' memberships and roles in the database, and write row-level security policies that check membership on every query. Enforce permissions in Postgres, not just the UI, and write automated tests that try to read and write across tenants.

Key takeaways
  • Every tenant-owned table needs a tenant ID column and RLS enabled, with no exceptions.
  • Put membership and permission checks in security definer helper functions so policies stay short.
  • Enforce permissions in the database; the front end only decides what to show.
  • Isolation needs its own tests that actively try to break it.
  • Treat the service role key as a master key that never reaches the browser.

Building multi-tenant SaaS with Supabase row-level security is one of those things that looks simple in a tutorial and gets serious quickly in production. A single forgotten policy on a single table can expose one customer's data to another. In a B2B product, that is the kind of bug that ends the company.

I built the RENXW CRM, a multi-tenant agency CRM on React, Vite, Supabase and PostgreSQL. It has 86 tables across 32 migrations, 15 system roles and 88 permissions enforced in the database, backed by 834 automated tests, 425 of which are tenant-isolation probes. These are the lessons I would pass on to anyone starting a similar build.

Why does RLS matter so much on Supabase?

With a traditional backend, the browser talks to your server, and your server talks to the database. Your server code is the gatekeeper.

Supabase is different. The browser talks to the database through Supabase's API, using the anon key and the user's JWT. That is what makes Supabase fast to build with. It also means row-level security is your gatekeeper. If a table has RLS disabled, or a policy is too broad, any signed-in user can query it directly from the browser console. Your React app's carefully hidden buttons do not matter.

Internalise that early and the rest of the design follows.

How should you model tenants?

For most B2B SaaS, a shared schema with a tenant ID on every row is the right starting point. Separate schemas or databases per tenant add operational weight that small teams rarely need.

The core tables:

  • tenants: one row per customer organisation.
  • memberships: links a user to a tenant, with a role. A user can belong to several tenants.
  • roles and permissions, with a join table between them, if you need more than two or three fixed roles.

Then every tenant-owned table gets a tenant_id column, not null, with a foreign key and an index. No exceptions, including lookup tables that "probably" do not need it. In my experience, the table you skipped is the one that leaks.

What does a good RLS policy look like?

Keep policies short and push the logic into helper functions. Here is a simplified version of the pattern:

-- Helper: is the current user a member of this tenant?
create or replace function public.is_member(t uuid)
returns boolean
language sql
security definer
set search_path = public
stable
as $$
  select exists (
    select 1 from memberships m
    where m.tenant_id = t
      and m.user_id = auth.uid()
  );
$$;

alter table leads enable row level security;

create policy "members can read their tenant's leads"
on leads for select
to authenticated
using ( (select public.is_member(tenant_id)) );

create policy "members with permission can insert leads"
on leads for insert
to authenticated
with check (
  (select public.has_permission(tenant_id, 'leads.create'))
);

A few details are doing real work here:

  • security definer lets the function read the memberships table even though the memberships table has its own RLS. Pin search_path so the function cannot be hijacked by objects in other schemas.
  • with check on inserts and updates stops a user writing a row into a tenant they do not belong to. A using clause alone does not cover that.
  • Wrapping the call in (select ...) lets Postgres evaluate it once per statement rather than once per row, which matters on big tables.
  • Separate policies per operation (select, insert, update, delete) are easier to reason about and test than one catch-all for all policy.

Where should permissions be enforced?

In the database. The front end should still hide buttons a user cannot use, because that is good UX. But hiding is not enforcing.

In RENXW CRM, all 88 permissions are enforced in Postgres, mapped to 15 system roles. The pattern is a has_permission(tenant_id, permission_key) function that joins the user's membership, role and the role's permissions. Policies call it. The React app calls a similar endpoint to decide what to render. Both read from the same tables, so the UI and the database cannot disagree about what a role is allowed to do.

This has a useful side effect: adding a new role is a data change, not a code change across dozens of components.

How do you prove tenants are isolated?

You test it, aggressively, on every migration.

Policies are code, and code regresses. A migration that adds a column, renames a table or replaces a policy can quietly open a hole. The only reliable defence is a test suite that behaves like a hostile user.

The approach I use:

  1. Seed at least two tenants with realistic data and users in several roles each.
  2. For every table, sign in as a user in tenant A and attempt to select, insert, update and delete tenant B's rows.
  3. Assert that reads return nothing and writes fail.
  4. Repeat for each role, because a policy might be correct for admins and wrong for a limited role.
  5. Check that tables have RLS enabled at all. A simple query against pg_tables that fails the build if any public table has rowsecurity = false catches the most dangerous mistake.

Those are the 425 isolation probes in the RENXW CRM suite. They are tedious to write. They are also the reason I can change the schema without holding my breath.

What are the common mistakes?

The ones I see most often in Supabase projects:

  • Enabling RLS with no policies and then "fixing" the empty results with using (true). That turns RLS off in all but name.
  • Using the service role key in the browser. It bypasses RLS entirely. It belongs only on the server, in edge functions or trusted backends.
  • Forgetting storage. Supabase Storage buckets have their own policies. Files need tenant-scoped paths and matching rules.
  • Views that bypass RLS. By default a Postgres view runs with the permissions of its owner. Use security_invoker on views so RLS still applies to the querying user.
  • Trusting tenant IDs sent from the client in RPC functions without checking membership inside the function.
  • No index on tenant_id. Fine with ten rows, painful with a million.

Should tenant and role live in the JWT?

You can add custom claims through Supabase's custom access token hook, and reading a claim is cheaper than a membership lookup. The catch is that tokens are only refreshed periodically, so a user removed from a tenant or demoted can keep their old access until the token expires.

For products where permissions change often, or where removal must be immediate, I prefer the membership table lookup. If you do use claims, keep token lifetimes short and treat claims as a hint, not the final word, for sensitive operations.

What about secrets and integrations?

Multi-tenant apps usually store per-tenant API keys and credentials. RENXW CRM has an encrypted credential vault for exactly this. The rule is simple: encrypt sensitive values at rest, make sure decryption only happens server-side, and never return decrypted values to the browser unless a user is actively viewing a credential they are entitled to see. RLS controls who can see the row. Encryption limits the damage if something else goes wrong.

You can read the full build story, including the screens and the Phase 2 plan, in the RENXW CRM case study. If automations are part of your plan, my post on n8n workflows for small businesses covers how I approach the integration side.

Need a hand?

If you are planning a SaaS product or a custom CRM on Supabase and want the data model and security right from the first migration, see my web app development service or read more about me. Then get in touch with what you are building.

Hamza Naqvi
Hamza Naqvi

Software engineer in Lahore. 200+ websites shipped since 2020, from WordPress and Shopify builds to multi-tenant SaaS and AI assistants. More about me.

Questions people ask

What is row-level security in Supabase?

Row-level security (RLS) is a PostgreSQL feature that Supabase exposes directly. When RLS is enabled on a table, every query is filtered by policies you write in SQL. Because Supabase lets the browser query the database through its API, RLS policies are what stop one user reading or changing another user's data.

Is Supabase suitable for multi-tenant SaaS?

Yes, with a shared-schema design where each row carries a tenant ID and RLS policies enforce isolation. It suits most B2B SaaS products well. It needs discipline: every new table must get RLS and policies, and you need tests that prove isolation holds as the schema grows.

Should I put the tenant ID in the JWT?

You can add tenant and role claims to the JWT with a custom access token hook, which saves a lookup per query. The trade-off is staleness: if a user's role or membership changes, the token is wrong until it refreshes. For permission-sensitive apps, checking a membership table is simpler and always current.

How do you test row-level security policies?

Create users in at least two tenants, sign in as each, and run every read, insert, update and delete against the other tenant's data, asserting it fails or returns nothing. Automate this so it runs on every migration. The RENXW CRM test suite has 834 automated tests, including 425 tenant-isolation probes.

Does row-level security slow down queries?

It can, if policies call expensive functions per row or filter on unindexed columns. Index the tenant ID column on every table, keep helper functions simple, and wrap function calls in a subselect so Postgres can evaluate them once per query rather than once per row. Check query plans on your largest tables.

Keep reading

Got a project in mind?

Tell me what you are building. I reply within one working day.

Start a project