Skip to content
Software architecture6 min read

Row-level security for multi-tenant Postgres

Tenant isolation that depends on every developer remembering a WHERE clause has a defect rate above zero, and the defect is a cross-tenant data leak. Row-level security moves the boundary into the database, so a query that forgets the filter returns nothing instead of returning somebody else's rows.

Part ofSaaS & PaaS Product Engineering

Choosing a tenancy model is the architectural decision. Enforcing it is the engineering one, and it is where most multi-tenant products quietly carry risk. A shared-schema design is entirely defensible — it is the cheapest to operate and suits most products — but only if isolation is enforced somewhere that cannot be forgotten.

The failure mode this prevents

In a shared-schema product, every query against a tenant-scoped table needs a tenant predicate. Add a reporting endpoint, a background job, an admin tool or a data export, and each one is a fresh opportunity to omit it. The omission does not throw. It returns rows — just the wrong ones.

  • A new endpoint written against a repository method that was never tenant-scoped
  • A background job that resolves tenant context from the wrong place, or loses it on retry
  • An aggregate query written for a dashboard that sums across every tenant
  • A CSV export that joins a table nobody remembered was tenant-scoped

Application-layer filtering catches most of these most of the time. Row-level security catches all of them every time, because the engine applies the predicate whether or not the query author remembered to.

The mechanics

PostgreSQL evaluates row-level security policies as an implicit predicate on every statement against a table where it is enabled. The policy reads tenant identity from a session setting, which the application sets per connection from the authenticated session — never from user input.

sql
-- 1. Enable RLS, and force it to apply to the table owner too.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

-- 2. The policy. USING filters reads; WITH CHECK constrains writes,
--    so a tenant cannot insert a row attributed to another tenant.
CREATE POLICY tenant_isolation ON invoices
  USING      (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

-- 3. The application role must not be able to bypass it.
--    Superusers and roles with BYPASSRLS ignore every policy.
CREATE ROLE app_user NOLOGIN NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;

Note the second argument to current_setting. Passing true makes a missing setting return NULL rather than raising an error. That choice matters, and it is worth being deliberate about — see the fail-closed section below.

Setting tenant context safely

The setting has to be applied to the same physical connection that runs the query, and it has to be cleared before that connection is reused. This is where pooled connections cause real bugs.

sql
-- Scope the setting to the transaction, not the session.
-- set_config(..., true) means "local to this transaction", so it is
-- discarded at COMMIT or ROLLBACK and cannot leak to the next borrower
-- of a pooled connection.
BEGIN;
SELECT set_config('app.tenant_id', $1, true);

-- ... application queries run here, all implicitly scoped ...

COMMIT;

Transaction pooling adds a further constraint. With PgBouncer in transaction mode, a connection is assigned per transaction rather than per session, which is compatible with transaction-local settings and incompatible with session-level ones. If you use statement-level pooling, neither works — every statement may land on a different connection.

Fail closed, not open

If app.tenant_id is never set, current_setting('app.tenant_id', true) returns NULL, the comparison evaluates to NULL, and the policy matches no rows. An unscoped query therefore returns an empty result rather than everything — which is the correct direction to fail, but it is a silent failure.

An empty result is easy to misread as "this tenant has no invoices." Make the failure loud instead: have the data-access layer refuse to run a query when no tenant context is set, and alert on it in production. The database is the backstop, not the primary signal.

ScenarioWithout RLSWith RLS
Query omits tenant predicateReturns every tenant's rowsReturns nothing
Insert with wrong tenant_idSucceeds silentlyRejected by WITH CHECK
Context never setReturns everythingReturns nothing
Connection reused from poolInherits previous tenantCleared at COMMIT
App connects as table ownerNo protectionProtected only with FORCE

The one test worth writing

Policies are configuration, and configuration drifts. The test that catches drift is not a unit test of the policy syntax — it is an integration test that attempts a cross-tenant read and asserts it fails.

  1. Seed two tenants with distinguishable rows.
  2. Open a transaction, set context to tenant A, and count rows. Assert only A's rows are visible.
  3. In the same test, attempt to select a row belonging to tenant B by its primary key. Assert zero rows.
  4. Attempt to insert a row carrying tenant B's id while scoped to A. Assert the write is rejected.
  5. Run a query with no tenant context set. Assert zero rows, and assert the application layer raised rather than returning empty.

Run it in CI on every change. It is the only mechanism that tells you the boundary still holds after a migration adds a table, a role changes, or someone grants BYPASSRLS to debug something on a Friday.

What this still does not cover

Row-level security protects the primary database. Tenancy leaks elsewhere, and those paths need their own scoping — RLS gives no help with any of them.

  • Cache keys that omit the tenant, serving one tenant's data to another
  • Search indexes queried without a tenant filter applied at query time
  • Object storage paths without tenant prefixes, and signed URLs scoped too broadly
  • Background jobs that resolve tenant context from a stale payload
  • Logs, traces and error reports that capture another tenant's payload

Used this way, row-level security converts a class of bug that depends on human vigilance into one the database refuses to allow. That is a worthwhile trade for a predicate you write once per table.

Questions

Does row-level security replace application-level tenant filtering?

No — it backs it up. Keep scoping in the data-access layer so queries stay efficient and intent stays readable, and treat RLS as the boundary that catches the query someone wrote without it. Defence in depth: the application layer is the primary control, the database is the one that cannot be forgotten.

What is the performance cost of RLS in PostgreSQL?

The policy is evaluated as an additional predicate, so it behaves like any other WHERE clause and uses indexes normally. The cost only becomes material when tenant_id is not indexed, or is not the leading column of composite indexes on tenant-scoped tables. Review query plans after enabling it rather than assuming it is free.

Does RLS work with connection pooling?

Yes, with transaction-level pooling, provided tenant context is set transaction-locally via set_config with the local flag so it is discarded at COMMIT. Session-level SET leaks the value to whichever request borrows that connection next. Statement-level pooling is incompatible, because consecutive statements may run on different connections.

Can a tenant escalate out of its own policy?

Not through SQL, provided the application role has NOBYPASSRLS, the table has FORCE ROW LEVEL SECURITY, and tenant identity is derived from the authenticated session rather than from any client-supplied value. The realistic escalation paths are application bugs that set the wrong context, not the policy itself.

Want this applied to your systems?

Most of what is described here begins as an assessment: a fixed-scope review producing a prioritised written report you can act on with any supplier.

Prefer email? hello@kornspire.com