Row-Level Security for Multi-Tenant SaaS: A Practical Pattern
Postgres row-level security is the right primitive for multi-tenant isolation. Here's the pattern we use in production.
If you're building multi-tenant SaaS on Postgres, Row Level Security-style row-level security is the safest primitive you can reach for. App-layer WHERE tenant_id = ? is one forgotten join away from a data breach. RLS pushes the check into the database where you can't forget it.
The pattern
- Store the tenant identifier on every row (
workspace_idin our schema). - Enable RLS on the table:
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY; - Add a policy that reads
auth.uid()and joins to a membership table. - Grant
SELECT/INSERT/UPDATE/DELETEtoauthenticated. - Roles live in a separate
user_rolestable — never on the profile.
The role table trick
Storing roles on the user or profile table is a privilege-escalation waiting to happen. Any RLS policy that reads its own row can be tricked. Split roles into user_roles, check via a SECURITY DEFINER function like has_role(auth.uid(), 'admin'), and the recursion vanishes.
Testing it
Write RLS tests that connect as a fake user and try to read another tenant's row. This is a five-line pgTAP test that pays for itself the first time someone refactors a policy.
What RLS doesn't solve
- Cross-tenant search indexes — those need application-layer filtering too.
- Signed file URLs — expire them or they'll outlive the permission that issued them.
- Realtime channels — verify subscription permissions on connect, not just on read.
Our secure client portal checklist covers the rest of the surface. Every X-com production table follows this pattern — see the trust center for the audit summary.
Try X-com free
Flat workspace pricing. Invite every client, partner, and collaborator without paying per seat.