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.

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_idon each data table and(user_id, workspace_id)onmemberships. - Claims. If a policy reads the JWT, Supabase warns that
user_metadatacan be modified by authenticated end users and points toapp_metadata, which users can't update. A role stored inuser_metadatais a role the user can grant themselves. - Unauthenticated calls.
auth.uid()returnsnullwithout a signed-in user, so a comparison silently matches nothing. Scope policies withTO authenticatedand 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:
- Add
workspace_idto each data table and backfill it with a seed workspace. - Create
workspacesandmemberships, and make existing users owners of the seed workspace. - Write the read, insert, update and delete policies for each table.
- Run
ALTER TABLE ... ENABLE ROW LEVEL SECURITYin the same transaction as the policies, since an enabled table with no policy returns no rows. - Confirm no browser code holds the secret key.
- 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.
