PostgreSQL Row-Level Security: Tenant Data Isolation for Multi-Tenant SaaS
Summary: Row-Level Security (RLS) is a PostgreSQL feature that lets the database decide which rows each query may read or write, based on the caller's identity. In multi-tenant SaaS it moves tenant isolation out of application code and into the database engine, where a forgotten WHERE clause no longer leaks data.
Contents
- Why one forgotten WHERE clause is the biggest risk in multi-tenant SaaS
- What is Row-Level Security?
- The five-layer tenant isolation control framework
- Implementing RLS in six steps
- Five mistakes that silently disable RLS
- When NOT to use RLS
- Frequently asked questions
Why one forgotten WHERE clause is the biggest risk in multi-tenant SaaS
Every multi-tenant SaaS application carries the same latent defect. Somewhere in the codebase there is a query that forgot AND tenant_id = $1.
It might be an endpoint written on a Friday afternoon, a background job running outside any request context, a CSV export, a search re-index, or an ORM call using the wrong repository method.
The application has hundreds of query paths. Tenant isolation holds only if every one is correct, forever, including code not yet written. That is not a security model. It is a hope.
This risk concentrates under load and on the code paths reviewed least. For Japanese enterprise buyers, cross-tenant leakage is a reportable incident that removes the commercial basis of a B2B SaaS product.
This article covers how to move the isolation guarantee from developer discipline into the database, and the traps that disable it silently — the architectural decision we apply in our system development service for Japanese enterprises.
What is Row-Level Security?
Row-Level Security (RLS) is a PostgreSQL feature that restricts read and write access at the level of individual rows, based on the identity or role of the querying user.
By default, any role with SELECT privilege reads every row in the table. Once RLS is enabled, PostgreSQL attaches a policy expression to every query touching it and evaluates that expression per row.
The official documentation is precise about ordering: the policy expression is evaluated for each row before any conditions or functions coming from the user's query (PostgreSQL Documentation §5.9, 2026).
That ordering is the whole point. The policy is applied before the application's own WHERE clause, so a developer who forgets the tenant filter still receives only their tenant's rows.
A policy has two halves, and both matter.
| Component | What it controls | Applies to |
|---|---|---|
USING |
Which existing rows are visible | SELECT, UPDATE, DELETE |
WITH CHECK |
Which new or modified rows are allowed | INSERT, UPDATE |
Without WITH CHECK, a tenant reads only their own rows but can still write rows stamped with another tenant's tenant_id — an isolation break in the opposite direction, and much harder to detect.
RLS is often confused with three other controls:
| Control | Enforced where | Prevents | Does not prevent |
|---|---|---|---|
| RLS | Database engine | Queries missing a tenant predicate | Faults in caches, jobs, object storage |
| Application-side filtering | Application code | Works while every query is correct | Any code path that is missed |
| GRANT / table privileges | Database engine | Access to a whole table | Cannot distinguish rows by owner |
| Encryption at rest | Storage layer | Reading files or backups directly | A valid query against the wrong tenant |
The five-layer tenant isolation control framework
RLS closes one class of failure, not all of them. When VAON audits a multi-tenant architecture, we work through the five layers below. The order reflects how often each layer actually fails, not how severe it is.
| Layer | Check question | Evidence required |
|---|---|---|
| 1. Schema | Does every tenant table have ENABLE and FORCE ROW LEVEL SECURITY? |
A pg_class query returning zero gaps |
| 2. Connection | Does the application connect as a role that is not the owner and not a superuser? | \du output plus the live connection string |
| 3. Session | Is the tenant context set per transaction or per session? | Context-setting code plus pooler configuration |
| 4. Query | Do indexes lead with tenant_id? Is the policy filter applied early or late? |
EXPLAIN (ANALYZE, BUFFERS) |
| 5. Beyond SQL | Do caches, jobs, search indexes and storage paths carry the tenant in their key? | Component-by-component review, not assumption |
The first three layers decide whether RLS works at all. Layer 4 decides whether the system survives load. Layer 5 sits outside RLS and needs its own controls.
One pattern stands out: in the systems we review, failures cluster in layers 2 and 3, not layer 1. The policies are written correctly — they are simply never enforced.
Implementing RLS in six steps
There are two ways to identify the current tenant: one PostgreSQL role per tenant, or the tenant identity passed as a runtime session variable. AWS states that the second option is preferred, because the first requires creating a new PostgreSQL user for every tenant (AWS Prescriptive Guidance, 2026).
Step 1 — Add the discriminator column. Every table holding tenant data carries tenant_id NOT NULL. Output: a consistent column across the whole schema.
Step 2 — Split the connection role. Create a dedicated application role holding only SELECT/INSERT/UPDATE/DELETE, and run migrations under a separate privileged role. Skipped most often; matters most.
Step 3 — Enable RLS with FORCE.
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
Step 4 — Write a policy with both halves.
CREATE POLICY tenant_isolation ON invoice
FOR ALL
TO app_user
USING (tenant_id = current_setting('app.current_tenant', true)::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::UUID);
The true argument in current_setting returns NULL instead of raising an error when the context is unset. The result is a query matching zero rows. The principle is fail closed, not fail open.
Step 5 — Set the context per transaction.
BEGIN;
SELECT set_config('app.current_tenant', $1, true); -- true = transaction-scoped
SELECT * FROM invoice ORDER BY issued_at DESC LIMIT 50;
COMMIT;
The tenant identifier comes from the verified server-side session or JWT, never from a client-controlled request parameter.
Step 6 — Index for the policy. The leading column of any index on a tenant table must be tenant_id, because that is the predicate RLS adds to every query.
CREATE INDEX invoice_tenant_issued_idx ON invoice (tenant_id, issued_at DESC);
What we learned in production
VAON built a construction site management system serving more than 200,000 users on the pool model — shared tables discriminated by tenant_id — with RLS designed in from the schema stage. Three problems we actually hit, ordered by how hard they were to detect:
The application connected as the table owner. The policies were correct and passed manual testing under a restricted user, but the real runtime used the owning role, so no policy was enforced. Nothing raised an error — queries simply returned more rows than they should have. The fix is FORCE ROW LEVEL SECURITY plus a role split; the detection method is an automated test, not code review.
Tenant context leaked through the connection pooler. A plain SET let the value survive the transaction and stay on the connection, which a pooler in transaction mode hands straight to another request. The symptom appears only under concurrency and is close to impossible to reproduce in staging. The fix is SET LOCAL or set_config(..., true).
Queries slowed because indexes led with the wrong column. Some queries degraded because the index did not start with tenant_id, so the planner fetched rows and filtered tenants afterwards. EXPLAIN (ANALYZE, BUFFERS) shows it: the policy filter applied late.
None of these are faults in RLS itself. They are faults in the environment around RLS — which is exactly why the five-layer framework above exists. How we move these decisions upstream is described in how VAON works.
Five mistakes that silently disable RLS
RLS fails quietly. When it is misconfigured you do not get an error — you get results. A query returning too much looks exactly like a query that worked.
1. Forgetting FORCE ROW LEVEL SECURITY. The PostgreSQL documentation states that superusers and roles with BYPASSRLS always bypass row security, and that table owners normally bypass it too unless ALTER TABLE ... FORCE ROW LEVEL SECURITY is used. Consequence: the policy exists but never runs. Avoid it by adding FORCE to every tenant table and separating the application role from the owner role.
2. Using SET instead of SET LOCAL behind a connection pooler. Consequence: one tenant reads another's data, non-deterministically, only under load. Avoid it with transaction scope.
3. Views running with owner privileges. Traditionally a view executes with the view owner's permissions, so a view owned by a privileged role becomes a hole straight through your policies. Avoid it on PostgreSQL 15 or later by declaring WITH (security_invoker = true) on every view over a tenant table.
4. Unique constraints defined globally. The documentation states that referential integrity checks always bypass row security, and warns about "covert channel" leaks. Concretely: with a global UNIQUE on email, a tenant receiving a duplicate-key error has just learned that another tenant uses that address. Avoid it with UNIQUE (tenant_id, email).
5. Ignoring the query planner. Only functions marked LEAKPROOF may be reordered ahead of the security check. Functions that are not leakproof cannot be pushed down, and the plan degrades to a sequential scan with a late filter. Supabase documents that indexing the column referenced by a policy produced over 100x improvement on large tables, and that wrapping a policy's function call in a scalar subquery took one test case from 178,000 ms to 12 ms (Supabase Docs, 2026).
Patch discipline is part of the control
RLS is engine code, and engine code gets CVEs. CVE-2024-10976 (CVSS 4.2) allowed a reused query plan to apply the wrong policy when the user ID changed; fixed in 17.1, 16.5, 15.9, 14.14, 13.17 and 12.21 (PostgreSQL Security, 2024). CVE-2025-8713 (CVSS 3.1) exposed sampled data through optimizer statistics from rows a policy was meant to hide; fixed in 17.6, 16.10, 15.14, 14.19 and 13.22, released 2025-08-14 (PostgreSQL Security, 2025).
Neither is trivially exploitable. The lesson is not severity, but that "we enabled RLS" is a claim with a version number attached.
When NOT to use RLS
RLS is not the right choice everywhere. Four situations call for something else:
The customer requires physical isolation. Some Japanese enterprise buyers, particularly in finance and healthcare, require data in a separate database or region as a contractual condition. RLS is logical isolation. Here the silo model is the correct answer, despite higher operating cost.
Very few tenants, each very large. With five to ten large tenants, database-per-tenant is simpler: easier backup, independent restore, easier to evidence in an audit.
You are not on PostgreSQL. RLS is a PostgreSQL feature. If you run an engine without an equivalent, do not pick a database for one feature — solve isolation at another layer with tighter controls.
Your team has no patching process. RLS moves a security guarantee into the engine. Without a routine for applying minor version updates, you are relocating risk rather than reducing it. If you need to assess operational readiness first, start with VAON's architecture audit.
To be explicit: VAON does not currently hold ISO 27001 certification. Our information security standards are built on the ISO 27001 framework, and certification is on the roadmap. We say so directly rather than letting customers find out during vendor assessment.
Frequently asked questions
Does RLS slow queries down?
Only if you keep indexing for the old access pattern. The policy adds tenant_id as a predicate on every query, so tenant_id must lead your composite indexes. Done correctly the overhead is negligible. Done incorrectly you get sequential scans with a late filter, which EXPLAIN (ANALYZE, BUFFERS) reveals immediately.
We already filter by tenant in the application. Is RLS redundant? No. RLS is the control that also covers the query you have not written yet. Application filtering is correct until one code path is wrong, and you cannot prove that never happens across a growing codebase. RLS lets you prove it from the system catalog.
Can we retrofit RLS onto a running production database? Yes, table by table. Enable it in a shadow environment first, run your isolation test suite, then roll out gradually. The riskiest part is not the policies — it is discovering that your application connects as the table owner and has therefore never enforced anything.
Does RLS replace an audit or a penetration test? No. It closes the missing-tenant-predicate class of failure, which is the largest one. Caches, background jobs, search indexes and object storage paths sit outside its scope and need their own controls.
Which PostgreSQL version do we need?
In practice 15 or later, because the security_invoker option for views only exists from 15. Whatever major version you run, stay on a patched minor release.
How do we check whether any table has slipped through?
Query pg_class joined with pg_policy, filtering on relrowsecurity and relforcerowsecurity. Any tenant table with rls_enabled = false is an incident waiting for traffic.
Conclusion
- RLS moves tenant isolation from developer discipline into the database, where it applies before any application query.
- The three most common failures sit not in the policies but around them: connection role, session scope, leading index column.
- Isolation must hold at every layer. RLS is the floor, not the ceiling.
If you are designing a multi-tenant schema, or you are not certain your current policies are actually enforced in production, book a free VAON architecture audit — we work through the five-layer framework above and hand back the findings with the verification queries.