All posts

Mar 2, 2026 · 8 min read · by Noor ur-Rehman

Why We Chose Postgres Row-Level Security for Multi-Tenant Finance

Multi-tenant data isolation is the kind of architectural decision that you live with for a decade. Getting it wrong means every deployment mode — self-host, SaaS, dedicated — has to be supported through code forks, and every feature ships in three shapes. Getting it right means you have one codebase and three deployment profiles, which is exactly what we wanted.

The three options, briefly: row-level with a tenant_id column on every tenanted table, schema-per-tenant, and database-per-tenant. There is a fourth — separate clusters — which is really the third at a different scale.

Schema-per-tenant gets a lot of love from engineers who have been burned by tenant_id leaks. The argument is: your ORM cannot accidentally return a row from another tenant if the other tenant's rows live in a different schema altogether. It is a fair argument. It is also, in our experience, an architecture that makes cross-tenant operations — consolidation, backup coordination, migration rollouts — much more painful than they need to be. And the isolation benefit is largely a social engineering advantage; a misconfigured search_path can still leak data across schemas.

Database-per-tenant trades off compute density for stronger isolation. Correct for regulated, enterprise, or sovereign-data workloads. Wrong for SMB SaaS where you are running 5,000 tenants on one instance and cannot afford the per-tenant connection overhead.

Row-level security, done properly, covers more ground than either. Postgres RLS gives you a declarative isolation policy at the database level. Every query that hits a tenanted table is filtered by the database — not the application, not the ORM. We set the tenant context via SET LOCAL app.current_tenant at the start of every request, and Postgres does the rest. We enable FORCE ROW LEVEL SECURITY so even the superuser has to play by the rules, which forecloses an entire class of mistakes.

The application layer still filters by tenant_id. We keep it as a second line of defence — belt and suspenders — and we rely on the Supabase-style practice of treating RLS policies like application code: version-controlled, tested, reviewed. There is a small performance cost. We measured it at 3–6% on our workload, which is an acceptable trade for the isolation guarantee.

Where RLS falls short is in cross-tenant operations. Backups, global audit logs, consolidation across entities a user has access to — all of these require temporarily lifting the tenant filter. We handle that with an explicit system role that is audit-logged on escalation and is unavailable to regular workers. It is a small gate but it is a gate.

The net effect is that our hosted SaaS tier, our single-tenant enterprise tier, and our self-hosted Community edition all run the same code. The only difference is deployment: shared schema with RLS for SaaS, separate schema per tenant for mid-market regulated customers who ask for it, separate database for enterprise. One codebase, three profiles. That is the entire point.