← Back to Field notes
WINROVE / Engineering: multi-tenancy

Where we put the multi-tenant boundary, and why we put it in the database.

Application-layer tenant predicates are a class of bug. Row-level security in Postgres is a class of guarantee. We chose the guarantee.

April 22, 2026 · Winrove Team

Cover illustration for Where we put the multi-tenant boundary, and why we put it in the database.

Two places to enforce a tenant boundary

In late 2023, a well-documented breach at a SaaS HR platform exposed employee records across dozens of employer accounts. The root cause, per the vendor's own post-mortem: a single API endpoint that accepted a tenant identifier from the request body and trusted it without re-validating against the authenticated session. One missing where-clause. The data for every tenant was in the same tables. The application was the only thing standing between them, and the application had a gap.

That incident is not unusual. It is the predictable failure mode of application-level tenancy. Every multi-tenant application has the same structural problem: a request arrives, the request belongs to a tenant, and the database holds rows for many tenants. Something has to make sure the query that executes is scoped to the right tenant. The question is where that enforcement lives.

There are exactly two places. In the application layer: every query carries a WHERE tenant_id = ? clause, and every developer on every code path is responsible for not forgetting it. In the database layer: the database itself rewrites every query to include a tenant predicate, derived from the session's authenticated identity, before the query planner ever sees it. The second is harder to set up. The first is easier to break, and the breakage is invisible until it is not.

What row-level security looks like in Postgres

Postgres has had row-level security (RLS) since version 9.5. The mechanics are straightforward: you enable RLS on a table, write one or more policies that define which rows a given role can read or write, and the database enforces those policies on every query, from every code path, without exception.

Our implementation uses a session-level configuration parameter. On every connection checkout from the pool, we call SET app.current_tenant = '<uuid>'. The RLS policy reads that parameter and applies it as a predicate:

ALTER TABLE onboardings ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON onboardings
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

From this point forward, SELECT * FROM onboardings from any application code path, any ORM, any raw query, any background job, returns only rows belonging to the tenant set on the current session. A developer who forgets the where-clause does not get all tenants' data. They get their tenant's data, because the database appended the predicate before planning the query. The class of bug where a missing filter leaks cross-tenant rows is structurally eliminated for that table.

The BYPASSRLS escape hatch

There are legitimate reasons to query across tenants: backfill migrations, compliance exports, support tooling, aggregate reporting. We handle these with a dedicated Postgres role that carries the BYPASSRLS privilege. That role is not used by the application. It is used only by named administrative tooling, and every connection made under it generates a distinct audit event type in our event log. If that event type appears outside a scheduled maintenance window, it triggers an alert. The escape hatch exists, but it is narrow, logged, and monitored.

What this costs: three honest numbers

RLS is not free. Anyone who tells you otherwise has not run it under load. Here are the three real costs we encountered.

1. Query planning overhead

Every query that touches an RLS-protected table has its plan generated with the policy predicate included. For point lookups by primary key, the overhead is negligible. For complex joins across multiple RLS-protected tables, the planner needs to see tenant_id in the right places to use indexes efficiently. We resolved this by adding covering indexes that include tenant_id as the leading column on any table where we saw plan regressions. After that change, we benchmarked our top-50 queries by frequency. P95 latency moved by less than 4 percent across the set. That is an acceptable and one-time cost.

2. Connection pool discipline

The session parameter has to be set on every connection checkout, without exception. We implement this in the pool's after-checkout hook. The critical design decision: if the hook fails to fire (application bug, misconfiguration, a new code path that bypasses the pool), the default policy is deny, not permit. A query that runs without app.current_tenant set returns zero rows, not all rows. We made this fail-closed deliberately. A developer who sees an unexpected empty result set will investigate. A developer who accidentally sees all tenants' data might not notice until much later.

This also means that integration tests have to set the session parameter explicitly. We added a test helper that wraps every database-touching test in the correct session setup. The first week after we introduced RLS, three tests failed because they were querying without a tenant context. That was the correct outcome. Those tests had been silently passing against unscoped data.

3. Debugging is different

When a query returns zero rows unexpectedly, there are now two possible causes: the data does not exist, or the policy is filtering it out. Early on, this caused confusion. The fix is instrumentation at the policy boundary. We log policy evaluations for slow queries and expose a debug mode (disabled in production, enabled in staging) that annotates query results with the effective policy predicate. We also wrote a standard diagnostic query that any developer can run to confirm what app.current_tenant is set to on their current session. These tools took about a sprint to build and have paid for themselves many times over in reduced debugging time.

Where RLS is not enough

RLS is a row-level guarantee within Postgres. It does not cover the rest of the stack, and it does not eliminate every category of multi-tenancy bug.

Specifically: RLS does not prevent you from writing to the wrong tenant's row if you obtained that row's primary key through a side channel and your session happens to be set to a different tenant. The application still has to validate that the resource being mutated belongs to the authenticated tenant before issuing the write. RLS will not stop a correctly-formed but logically-wrong write.

RLS also does not extend to non-Postgres systems. Winrove's object storage (contractor-uploaded documents, signed offer letters, I-9 supporting documents), event bus, and search index each enforce tenancy through their own mechanisms. Our audit chain records cross-system boundaries explicitly, so we can verify after the fact that the application kept its tenancy promises at each hop. The Postgres RLS boundary is the most important one because it covers the most sensitive structured data, but it is one layer in a defense-in-depth posture, not the whole posture.

When you should reach for RLS

The setup cost is real but bounded. It is paid once per schema change, once per developer onboarding (here is the pool hook, here is the policy template, here is how to query the audit log for BYPASSRLS use), and once per new table added to the schema. The ongoing cost is the index discipline described above.

The floor it sets is meaningfully higher than what application-level tenancy can deliver, because it does not depend on every developer remembering every time. It depends on the database, which does not forget.

If your tenants are genuinely insensitive to each other, RLS is probably more complexity than the risk warrants. If your tenants are organizations whose data carries legal, regulatory, or contractual sensitivity, the calculus is different. Federal subcontractor onboarding data (I-9 records, background investigation inputs, PIV enrollment data) sits at the high end of that sensitivity spectrum. We did not want our tenancy guarantee to depend on developer discipline. We wanted it to depend on Postgres. We chose the database, and we have not regretted it.

Practical starting point

If you are evaluating RLS for the first time, start with one table that holds your most sensitive data. Enable RLS, write a single USING policy, add the session-parameter hook to your pool, and run your existing test suite. The tests that fail will show you exactly where your application was relying on unscoped queries. That list is your remediation backlog, and it is better to find it in a test run than in a post-mortem. For more on how Winrove handles multi-tenant compliance data for federal contractor onboarding, see winrove.com.

Preserved Field Notes article. Original path /blog/rls-tenant-isolation/. No unrelated help guide has been substituted.

Related Field notes

Browse the Field notes index.