Row-Level Security in Postgres for Multi-Tenant SaaS: What Actually Works.
Digital Engineering
Postgres RLS is the right primitive for tenant isolation. The implementations we see in production usually misuse it in ways that are slow, leaky, or both. A practitioner's guide to doing it correctly.
By Arjun Raghavan, Security & Systems Lead, BIPI · April 17, 2026 · 8 min read
Postgres has had row-level security since version 9.5. It is the right primitive for multi-tenant SaaS where you want database-enforced isolation between tenants. We have seen it work brilliantly. We have also seen it deployed in ways that delivered no isolation, no performance, or both. The difference is in the details.
The right mental model
RLS attaches a WHERE clause to every query against a protected table. The clause is set in policy, not in the query. The application code does SELECT * FROM accounts and Postgres rewrites it to SELECT * FROM accounts WHERE tenant_id = current_setting('app.current_tenant')::uuid. The application sets the tenant_id on every connection. Even a buggy query that forgets to filter cannot leak across tenants.
When it works, it is the strongest tenant isolation guarantee you can give. When it does not work, it is because the policies are not actually enforced.
The first mistake: BYPASSRLS roles
Every Postgres role has a BYPASSRLS attribute. Superuser implicitly has it. Application database users frequently get it during initial setup because the developer was tired of fighting policies. Once the role can bypass RLS, the protection is gone for queries from that role.
The fix is explicit: ALTER ROLE app_user NOBYPASSRLS, audit every role's bypass status quarterly. Use a separate role for migrations (BYPASSRLS) and runtime queries (NOBYPASSRLS). Migrations should never run with the runtime role.
The second mistake: forgetting the join tables
RLS policies are per-table. A typical schema has 50+ tables. The team enables RLS on the 5 'main' tables (accounts, users, projects) and forgets the join tables (project_members, document_tags, audit_logs). A query that joins accounts to audit_logs leaks audit log rows from other tenants because the audit_logs table has no policy.
The fix is enable-by-default at table creation. Migration tooling should refuse to create a tenant-scoped table without an associated policy. We use a custom Postgres migration check that fails the migration if a new tenant_id column appears without an enabled RLS policy.
RLS is only as strong as the table you forgot. Most leaks happen through join tables and audit / metadata tables that the team did not consider tenant-scoped.
The third mistake: setting tenant in application code per-query
Some implementations do SET LOCAL app.current_tenant = $1 at the start of every query. If the application forgets, current_tenant is empty, the policy returns no rows, the application thinks the tenant has no data, and may create duplicates. Worse: if the connection is reused without resetting, the previous tenant's value persists.
The fix is connection-pool-level: configure the pool (PgBouncer transaction mode, or similar) to inject the tenant context based on the application's auth state. The application does not control the tenant value per-query; the pool does, and the pool gets the value from a JWT or session lookup.
The fourth mistake: RLS without indexes on tenant_id
RLS adds a WHERE tenant_id = X to every query. If the table is not indexed on tenant_id, every query is now a full table scan filtered post-hoc. Performance collapses, especially on tables that grew large under a non-RLS schema and were retrofitted.
The fix is composite indexes that lead with tenant_id. Existing indexes on (created_at) become (tenant_id, created_at). Existing unique indexes on (email) become (tenant_id, email) which also fixes a common multi-tenant bug where uniqueness was enforced globally instead of per-tenant.
The fifth mistake: tests that bypass RLS
Test fixtures often run as a privileged role to set up data quickly. The same tests then run application queries through the privileged role, and never exercise the RLS path. Tests pass. The RLS policy could be wrong and the tests would not catch it.
The fix is two test phases: setup runs privileged, assertions run as the application role with tenant context set. We have a test helper that wraps each test case in BEGIN; SET LOCAL ROLE app_user; SET LOCAL app.current_tenant = $1; ...; ROLLBACK; which mirrors production exactly.
What good looks like
- Every tenant-scoped table has an enabled RLS policy.
- Application connects with a NOBYPASSRLS role.
- Tenant context is set at the connection level, not per-query.
- Composite indexes lead with tenant_id on every protected table.
- Tests run application queries through the same role and policy that production does.
- Audit query: SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename NOT IN (SELECT tablename FROM pg_policies) flags any table without a policy.
Closing
RLS is the strongest tenant isolation primitive in the Postgres ecosystem. Done correctly, it survives application bugs that would leak data in any application-only isolation scheme. Done incorrectly, it provides false confidence and shipping a multi-tenant SaaS with broken RLS is worse than shipping one with no RLS, because nobody is looking. Get the details right, automate the audit, and you have an isolation guarantee you can build a business on.
Read more field notes, explore our services, or get in touch at info@bipi.in. Privacy Policy · Terms.