PostgreSQL Row Level Security is underused by application developers
Most teams put data isolation in application code — every new query path is a new potential hole. Here's why the database layer is almost always better.
The problem with application-layer isolation
Most Rails, Django, and Node apps implement multi-tenant data isolation the same way:
SELECT * FROM orders WHERE tenant_id = $1
It works. Until a developer adds a new endpoint, forgets the WHERE clause, and one tenant reads another’s data. Code review catches most of these. Not all.
The problem is structural: application code is the wrong enforcement layer. It’s opt-in, it fails open, and it has to be re-implemented on every new query path.
What RLS actually does
Row Level Security attaches access policies directly to tables. Every query — regardless of origin — is automatically filtered. You define the policy once. It applies to raw psql, migrations, analytics tools, ORMs, and code that doesn’t exist yet.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Now no query can return rows from a different tenant. Not your ORM, not a reporting script, not a junior dev who forgot the WHERE clause.
Setting the context at the connection level
The policy reads from a session variable. You set it once per request, right after you acquire the connection:
async function withTenantContext<T>(
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect()
try {
await client.query(`SET LOCAL app.current_tenant_id = $1`, [tenantId])
return await fn(client)
} finally {
client.release()
}
}
SET LOCAL scopes the variable to the current transaction. When the connection returns to the pool, the context is gone.
The BYPASSRLS escape hatch
Some operations legitimately need to read across tenants: background jobs, admin queries, cross-tenant analytics. PostgreSQL handles this cleanly.
Create a separate database role for those operations and grant it BYPASSRLS:
CREATE ROLE app_admin BYPASSRLS;
Use a separate connection pool with those credentials for privileged operations. Your application’s normal connection pool uses a role without BYPASSRLS. The policy enforces the boundary automatically.
What this doesn’t replace
RLS is not a substitute for proper application-layer authorization. It prevents data leakage at the database — it doesn’t control what actions a user can take within their own tenant. You still need to check whether a user can modify an order, not just whether the row belongs to their tenant.
Think of it as a safety net, not the primary fence.
The real reason teams don’t use it
Complexity isn’t the issue. The migration path is: add the policy, test it against your existing queries, update your connection setup to set the context variable. A day of work for a medium-sized codebase.
The real reason is that it requires thinking about security at the schema design phase. That’s where it belongs.