← All articles
Engineering · 6 min read

PostgreSQL Row-Level Security: Enforce Tenancy in the Database

By Romanov Solutions · June 3, 2026

Every multi-tenant SaaS application has the same quiet risk: a missing WHERE tenant_id = $1 clause. One careless query, one rushed feature branch, and a customer sees another customer's data. Application-layer tenant filtering is necessary but not sufficient. PostgreSQL Row-Level Security (RLS) closes the gap by enforcing isolation at the storage engine itself—before a single row leaves the database.

Why Application-Layer Checks Alone Are Not Enough

In a typical multi-tenant setup, the application passes a tenant identifier and filters every query. This works until it doesn't: an ORM abstraction leaks a base query, a background job runs under a superuser connection, a new engineer forgets the filter, or a raw SQL report gets written in a hurry. Each of these is a real incident waiting to happen. RLS makes the database itself the last line of defense—one that doesn't forget and can't be argued with.

How PostgreSQL RLS Actually Works

RLS lets you attach security policies directly to a table. When a policy is active, every SELECT, INSERT, UPDATE, and DELETE against that table is automatically filtered by the policy expression. Rows that don't satisfy the policy are invisible—not forbidden with an error, just absent, which is exactly the behavior you want for tenant isolation.

The core setup is three steps:

A Concrete Policy Example

Suppose you have an appointments table with a practice_id column. You want each application session to see only rows belonging to its practice. First, you store the current practice ID in a session-local configuration variable:

SET app.current_practice_id = '42';

Then you write a policy that reads that variable:

CREATE POLICY tenant_isolation ON appointments USING (practice_id = current_setting('app.current_practice_id')::integer);

From this point forward, any query against appointments under a non-superuser role automatically receives an implicit WHERE practice_id = 42. The application doesn't have to remember. The ORM doesn't have to remember. The database enforces it unconditionally.

Roles, Superusers, and the Bypass Trap

One critical detail: superuser roles and table owners bypass RLS by default. This is the most common pitfall we see in production setups. Your application should connect as a dedicated, least-privilege role—never as postgres or the table owner. If you need a privileged role for migrations, run migrations in a separate connection context and never use that role for application queries. You can also explicitly mark a role as subject to RLS with ALTER ROLE app_user BYPASSRLS = false; to be explicit about intent.

Handling Background Jobs and Reporting Queries

Background workers and analytics queries often need cross-tenant access. The right pattern is not to bypass RLS—it's to set the session variable to a sentinel value and write a separate policy clause for administrative roles, or to use a dedicated reporting role with its own broader policy. This keeps the audit trail clean and avoids the habit of running everything as a superuser.

For Open Dental integration pipelines at Romanov Solutions, we use this exact pattern: the sync worker sets app.current_practice_id at the start of each job iteration, processes one practice's records, then resets it before moving to the next. Cross-contamination becomes structurally impossible.

Performance Considerations

A common objection is performance overhead. In practice, Postgres evaluates RLS policies before the query planner finalizes its plan, so the policy expression participates in index selection. A policy on an indexed practice_id column adds negligible overhead—the planner treats it like any other predicate. Where you can get hurt is with complex policy expressions involving subqueries or function calls that execute per-row. Keep policies simple: equality checks against session variables are fast.

Practical Takeaway

Enable RLS on every table that holds tenant-scoped data, connect your application as a non-owner least-privilege role, and set the tenant context variable at session open time—ideally in your connection pool's on_connect hook. This single architectural decision eliminates an entire class of data-leakage bugs and makes your tenant isolation auditable, testable, and independent of application code quality. If you're running a multi-tenant Postgres system without RLS today, that's the first thing to fix before your next feature ships.

postgresql row-level securitymulti-tenant architecturedatabase securityrls policiessaas data isolationpostgres enterprisetenant data protection
Was this useful?
Ask AI