Multi tenant SaaS with Supabase Row Level Security: how I isolate fifty migrations across tenants
Multi tenant SaaS sounds simple until you ship your second tenant. The second tenant is where you discover every place you forgot to filter by tenant_id. The third tenant is where you discover every place Postgres was happy to let you forget.
I learned this building OrderBind, a multi tenant order management SaaS with more than fifty migrations to date. Below is the isolation model I trust, the way the policies are layered, and the patterns that kept the migration count from becoming chaos.
The shape of tenant isolation
Every domain table in OrderBind has a non null tenant_id uuid column with a foreign key to tenants(id). There is no soft tenancy, no shared rows. If a row exists, it belongs to exactly one tenant. Reports, audit logs, attachments, everything.
That single rule gives Row Level Security the only fact it needs.
The policy stack
OrderBind uses three roles per tenant: super_admin, admin, and member. Membership lives in a tenant_users table joining auth.users to tenants with a role. Every domain table reads the user identity from Supabase auth, joins tenant_users, and decides what to allow.
The pattern I use everywhere:
create policy "tenant members read"
on public.orders
for select
to authenticated
using (
tenant_id in (
select tenant_id from public.tenant_users
where user_id = auth.uid()
)
);
create policy "tenant admins write"
on public.orders
for all
to authenticated
using (
tenant_id in (
select tenant_id from public.tenant_users
where user_id = auth.uid()
and role in ('admin', 'super_admin')
)
)
with check (
tenant_id in (
select tenant_id from public.tenant_users
where user_id = auth.uid()
and role in ('admin', 'super_admin')
)
);Read policy is permissive across the tenant. Write policy is restricted to elevated roles. Members see everything inside their tenant but can only mutate what their role permits.
Why this works without a billion joins
People worry about RLS performance because the policy subquery looks expensive. In practice Postgres plans it as a semi join, and tenant_users(user_id, tenant_id, role) is the index that makes it fly. Add a covering index, set tenant_id as the leading column on big tables, and the planner does the right thing.
Migrations as a discipline, not a folder
With fifty migrations, the only thing that keeps you honest is a convention. OrderBind uses two rules:
- Every migration is idempotent.
create table if not exists,drop policy if existsbeforecreate policy. Re-running the file does nothing the second time. - Every new domain table includes RLS in the same migration that creates the table. Never a separate file later. If you split them, you ship a window where the table is readable by anyone, and that window will be the production deploy.
What I would tell hiring managers
If you are looking at a multi tenant SaaS candidate, the question to ask is not "do you know RLS." Everyone says yes. The question is "show me a migration where you added a table and the policy at the same time, and tell me what would have happened if you had not."
That answer separates engineers who have shipped multi tenant systems from engineers who have read about them.
Md. Tausif Hossain leads engineering at DevTechGuru and ships SaaS independently as TechnicalBind. He is currently open to remote Senior and Staff engineering roles. Reach him at tausif1337.dev.
Newsletter
Get new posts in your inbox.
Honest essays on engineering, leadership, and the things I’m figuring out. No spam, ever.