PostgreSQL Row-Level Security for Multi-Tenant Tables: Policies, Pools, and Bypass Rules
A tenant policy that fails closed, setting the tenant safely through a pool, and the roles that bypass RLS, tested on PostgreSQL 18.6, including a leak through a reused connection.
If several tenants share a table, every query must include the tenant filter. Application code usually does, and occasionally does not: an ORM scope gets skipped, a report query is written by hand, an admin endpoint forgets. PostgreSQL row-level security (RLS) adds a second line of defense inside the database, applying a tenant predicate to queries run by the application's role whatever SQL the application sends.
RLS is effective, and it has precise exceptions that decide whether it protects you at all. This guide covers a policy that fails closed, setting the tenant safely through a connection pool, the roles that bypass RLS, and performance. Every rule is linked to the PostgreSQL 18 documentation, and a lab runs each one against PostgreSQL 18.6. Two results are worth knowing before you read on: a session-level SET leaked one tenant's rows to the next request on the same connection, and a tenant setting built with string formatting let a request for tenant B read tenant A's documents.
A policy that fails closed
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- see "Who bypasses RLS"
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
How each part behaves, per the documentation:
- With RLS enabled and no policy, PostgreSQL uses a default-deny policy: no rows are visible or modifiable (Row Security Policies).
current_setting(name, true)returnsNULLinstead of an error when the setting does not exist (System Administration Functions).NULLIF(..., '')also turns an empty string intoNULL, which a custom setting can hold once it has been used in a session.tenant_id = NULLis never true, so a connection that forgot to set the tenant sees no rows and cannot insert or update any.- For a policy without a
WITH CHECKclause that applies to all commands, theUSINGexpression is also used to check new rows (CREATE POLICY), so a tenant cannot insert a row with another tenant's ID.
Setting the tenant through a connection pool
Pooled connections are reused by different requests. A session-level setting such as SET app.tenant_id = '...' stays on the connection after the request ends and will be visible to the next request that borrows it, unless that request overwrites it.
Set the tenant per transaction instead. set_config(name, value, true) applies the value only to the current transaction (System Administration Functions), and because it is a function call, the value can be a bind parameter. That matters: building SET LOCAL app.tenant_id = '<value>' by string formatting, as many examples do, puts request data into SQL text.
The wrapper below is the only code that touches the raw database handle; everything else receives a transaction that already has its tenant. It compiles with Go 1.26 and only uses database/sql; the Go code itself was not run against a database, but the lab below runs the same set_config call and shows what happens with the string-formatted alternative.
// Package tenantdb is the only package that may use the raw *sql.DB.
// Every query runs in a transaction whose tenant is set for row-level security.
package tenantdb
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
)
var uuidRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
type DB struct{ raw *sql.DB }
func New(raw *sql.DB) *DB { return &DB{raw: raw} }
// Tx runs fn in a transaction scoped to one tenant. The tenant is passed as a
// bind parameter to set_config(..., true), so it lasts only for this
// transaction and is never spliced into SQL text.
func (d *DB) Tx(ctx context.Context, tenantID string, fn func(*sql.Tx) error) (err error) {
if !uuidRE.MatchString(tenantID) {
return errors.New("tenantdb: tenant ID is not a UUID")
}
tx, err := d.raw.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if _, err = tx.ExecContext(ctx, `SELECT set_config('app.tenant_id', $1, true)`, tenantID); err != nil {
return fmt.Errorf("tenantdb: set tenant: %w", err)
}
if err = fn(tx); err != nil {
return err
}
return tx.Commit()
}
Transaction-scoped settings work with PgBouncer in transaction pooling mode, where a client keeps a server connection only for the length of a transaction. Session-level settings do not, because the next transaction may run on a different server connection; see PgBouncer's configuration documentation on pool modes.
Who bypasses RLS
From the Row Security Policies documentation:
| Role or operation | Subject to policies? |
|---|---|
| Superusers | No, always bypass |
Roles with the BYPASSRLS attribute |
No, always bypass |
| The table's owner | Normally no; yes if the table has FORCE ROW LEVEL SECURITY |
| Referential integrity checks (foreign keys, unique and primary keys) | No, always bypass, so design schemas to avoid leaking existence through constraint errors |
| An ordinary role granted access to the table | Yes |
The practical setup is two roles. A migration role owns the tables and runs schema changes. An application role owns nothing, has neither superuser nor BYPASSRLS, and is granted only the privileges it needs. FORCE ROW LEVEL SECURITY then protects you if someone later runs application code as the owner.
Check the application role in CI and in production with:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'app_user';
Both flags must be false.
Performance
A policy adds its expression to every query on the table. With an index whose leading column is tenant_id and a policy that compares tenant_id with a value computed once per query, the planner can usually use the index to narrow to one tenant. Two things to know:
- PostgreSQL applies policy conditions before conditions from the query, except functions and operators marked
LEAKPROOF, which it may evaluate first (both the CREATE POLICY and Row Security Policies pages describe this). A non-leakproof function in a query'sWHEREclause can therefore change which index the planner can use. If a protected query is slow, compare its plan as the application role with the same query as an unrestricted role. - Measure with
EXPLAIN (ANALYZE, BUFFERS)while connected as the application role, with the tenant set. A plan captured as a superuser bypasses RLS and tells you nothing about the protected query.
Not every index should start with tenant_id. A globally unique column, such as an external ID used for lookups, may be better indexed on its own. Decide index by index from the queries that use it.
What RLS does not cover
RLS protects rows in this database, read by roles subject to the policy. It does not protect caches, search indexes, object storage, exports produced by privileged jobs, or logical replicas consumed by other systems. It also enforces exactly the policy you wrote; a policy with a wrong condition is enforced just as reliably. Treat it as one layer, as the cross-tenant leak guide describes, alongside a tenant-scoped data access package and cross-tenant tests.
Lab: row-level security, run for real
The program uses PGlite, which runs real PostgreSQL (here 18.6) inside Node.js as WebAssembly, so it needs no database server. PGlite runs a single session that starts as the superuser, and the lab switches roles with SET ROLE to act as the application, the table owner, and a reporting role. It checks, in order:
- default deny and the fail-closed policy;
- what a reused pooled connection sees after a session-level
SETcompared withset_config(..., true); - a tenant setting built with string formatting, using a value an attacker could send, compared with the same value as a bind parameter;
- which roles bypass the policy, before and after
FORCE ROW LEVEL SECURITY; - the query plan as the application role.
Save it as rls.mjs, then run npm install @electric-sql/pglite@0.5.10 and node rls.mjs with a current Node.js LTS release.
// Lab: PostgreSQL row-level security, run for real.
// PGlite runs PostgreSQL in-process (WebAssembly), as one session that starts
// as the superuser "postgres". The lab switches roles with SET ROLE.
// Run: npm install @electric-sql/pglite@0.5.10 && node rls.mjs
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
const A = '11111111-1111-1111-1111-111111111111';
const B = '22222222-2222-2222-2222-222222222222';
const one = async (sql, params) => (await db.query(sql, params)).rows[0];
const step = async (label, fn) => {
try {
console.log(label.padEnd(58), '->', await fn());
} catch (e) {
console.log(label.padEnd(58), '-> ERROR:', e.message);
await db.exec('ROLLBACK').catch(() => {});
}
};
const count = async () => (await one('SELECT count(*)::int AS n FROM documents')).n;
await db.exec(`
CREATE ROLE rls_owner NOLOGIN;
CREATE ROLE app_user NOLOGIN;
CREATE TABLE documents (id serial PRIMARY KEY, tenant_id uuid NOT NULL, body text);
ALTER TABLE documents OWNER TO rls_owner;
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO app_user;
GRANT USAGE ON SEQUENCE documents_id_seq TO app_user;
INSERT INTO documents (tenant_id, body) VALUES ('${A}', 'A1'), ('${A}', 'A2'), ('${B}', 'B1');
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
`);
console.log(await one('SELECT version()'));
console.log('\n1) Default deny and the fail-closed policy');
await db.exec('SET ROLE app_user');
await step('RLS enabled, no policy yet: rows visible', count);
await db.exec('RESET ROLE');
await db.exec(`CREATE POLICY tenant_isolation ON documents
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)`);
await db.exec('SET ROLE app_user');
await step('policy added, tenant not set: rows visible', count);
await step('set_config(..., true) for tenant A in a transaction', async () => {
await db.exec('BEGIN');
await db.query("SELECT set_config('app.tenant_id', $1, true)", [A]);
const rows = (await db.query('SELECT body FROM documents ORDER BY id')).rows.map(r => r.body);
return 'sees ' + JSON.stringify(rows);
});
await step('...same transaction: insert a row for tenant B', async () => {
await db.query('INSERT INTO documents (tenant_id, body) VALUES ($1, $2)', [B, 'X']);
return 'inserted';
});
await step('after the transaction ends: rows visible', count);
console.log('\n2) A pooled connection reused by the next request');
await step('request 1: session-level SET app.tenant_id = A', async () => {
await db.exec(`SET app.tenant_id = '${A}'`);
return 'sees ' + (await count()) + ' rows';
});
await step('request 2 (tenant B, forgot to set tenant): rows visible', async () => {
const rows = (await db.query('SELECT body FROM documents ORDER BY id')).rows.map(r => r.body);
return JSON.stringify(rows) + ' <- tenant A data';
});
await db.exec('RESET app.tenant_id');
await step('same, but request 1 used set_config(..., true)', async () => {
await db.exec('BEGIN');
await db.query("SELECT set_config('app.tenant_id', $1, true)", [A]);
await db.exec('COMMIT');
return 'request 2 sees ' + (await count()) + ' rows';
});
console.log('\n3) Building SET LOCAL with string formatting');
const attacker = `${B}'; SET LOCAL app.tenant_id = '${A}`; // value taken from a request
await step('SET LOCAL built with string formatting, tenant B request', async () => {
await db.exec('BEGIN');
await db.exec(`SET LOCAL app.tenant_id = '${attacker}'`);
const rows = (await db.query('SELECT body FROM documents ORDER BY id')).rows.map(r => r.body);
await db.exec('ROLLBACK');
return 'sees ' + JSON.stringify(rows);
});
await step('same value passed as a bind parameter to set_config', async () => {
await db.exec('BEGIN');
await db.query("SELECT set_config('app.tenant_id', $1, true)", [attacker]);
const n = await count();
await db.exec('ROLLBACK');
return n;
});
console.log('\n4) Who bypasses the policy (no tenant set)');
await db.exec('RESET ROLE');
await step('superuser postgres: rows visible', count);
await db.exec('SET ROLE rls_owner');
await step('table owner, without FORCE: rows visible', count);
await db.exec('RESET ROLE');
await db.exec('ALTER TABLE documents FORCE ROW LEVEL SECURITY');
await db.exec('SET ROLE rls_owner');
await step('table owner, with FORCE: rows visible', count);
await db.exec('RESET ROLE');
await db.exec('CREATE ROLE reporting NOLOGIN BYPASSRLS; GRANT SELECT ON documents TO reporting');
await db.exec('SET ROLE reporting');
await step('role with BYPASSRLS: rows visible', count);
await db.exec('RESET ROLE');
console.log('\n5) The plan as the application role (tenant A set)');
await db.exec('CREATE INDEX documents_tenant_idx ON documents (tenant_id)');
await db.exec('SET ROLE app_user');
await db.exec('BEGIN');
await db.query("SELECT set_config('app.tenant_id', $1, true)", [A]);
await db.exec('SET LOCAL enable_seqscan = off'); // tiny table: force the planner to show index use
const plan = await db.query('EXPLAIN (COSTS OFF) SELECT body FROM documents');
for (const r of plan.rows) console.log(' ' + r['QUERY PLAN']);
await db.exec('ROLLBACK');
await db.close();
Output recorded on November 3, 2026 (Node.js 22.23.2, PGlite 0.5.10, Windows 10):
{
version: 'PostgreSQL 18.6 (PGlite 0.5.10) on wasm32-unknown-emscripten, compiled by emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 6.0.5 (1db513782be24469589d7cb8a1f1834e9a33f271), 32-bit'
}
1) Default deny and the fail-closed policy
RLS enabled, no policy yet: rows visible -> 0
policy added, tenant not set: rows visible -> 0
set_config(..., true) for tenant A in a transaction -> sees ["A1","A2"]
...same transaction: insert a row for tenant B -> ERROR: new row violates row-level security policy for table "documents"
after the transaction ends: rows visible -> 0
2) A pooled connection reused by the next request
request 1: session-level SET app.tenant_id = A -> sees 2 rows
request 2 (tenant B, forgot to set tenant): rows visible -> ["A1","A2"] <- tenant A data
same, but request 1 used set_config(..., true) -> request 2 sees 0 rows
3) Building SET LOCAL with string formatting
SET LOCAL built with string formatting, tenant B request -> sees ["A1","A2"]
same value passed as a bind parameter to set_config -> ERROR: invalid input syntax for type uuid: "22222222-2222-2222-2222-222222222222'; SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111"
4) Who bypasses the policy (no tenant set)
superuser postgres: rows visible -> 3
table owner, without FORCE: rows visible -> 3
table owner, with FORCE: rows visible -> 0
role with BYPASSRLS: rows visible -> 3
5) The plan as the application role (tenant A set)
Index Scan using documents_tenant_idx on documents
Index Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::uuid)
What the output shows
- The policy fails closed. With RLS enabled and no policy, and with a policy but no tenant set, the application role saw 0 rows. The insert for another tenant was rejected with
new row violates row-level security policy, and the transaction-local setting was gone once the transaction ended. - A session-level
SETleaks across requests. Request 1 set tenant A withSET. Request 2 on the same connection did not set a tenant and saw tenant A's documents. Withset_config(..., true)the same sequence left request 2 with 0 rows. This is exactly what happens behind a connection pool. - String formatting turned a tenant value into SQL. The attacker's value closed the quote and appended a second
SET LOCAL, so a request that claimed to be tenant B saw tenant A's documents. Passed as a bind parameter, the same value was treated as data and failed the UUID cast, so the query errored instead of returning rows. - Bypass rules match the documentation. The superuser, the table owner without
FORCE, and a role withBYPASSRLSall saw all 3 rows with no tenant set. WithFORCE, the owner saw 0. - The policy is part of the plan. With the tenant set, the planner used the
tenant_idindex with the policy expression as the index condition.
PGlite is single-session, so the "pooled connection" is simulated by running two requests one after the other on the same session, which is what a pool does.
Where to start
Run the role check above against production. If the application connects as a superuser, as the table owner without FORCE, or with BYPASSRLS, your policies are not protecting anything yet. Then replace any session-level SET of the tenant with set_config(..., true) inside a transaction, and add a CI test that a query without a tenant returns zero rows. The isolation model guide helps decide when a tenant needs more than a shared table with RLS.
Frequently Asked Questions
Does RLS slow queries down?
It adds the policy condition to each query. With an index that starts with tenant_id and a simple equality policy, the overhead is often small, but measure your own queries as the application role, because non-leakproof functions in a query can change the plan.
Can I use RLS with PgBouncer in transaction mode?
Yes, if the tenant is set inside each transaction with set_config(..., true). Session-level SET does not work reliably in transaction mode, because consecutive transactions may run on different server connections.
How do background jobs and admin tools access all tenants?
Give them a separate role and connection, and audit its use. Avoid adding BYPASSRLS to the application role for convenience, because then every application query bypasses the policy.
Should the tenant ID be a UUID or an integer?
Either works with RLS. Whatever the type, validate it before setting it, and keep the cast in the policy consistent with the column type so a bad value fails rather than matching nothing silently.
Can foreign keys leak data across tenants?
Referential integrity checks bypass RLS, so a constraint error can confirm that a referenced row exists. Include tenant_id in foreign keys and unique constraints where tenants should not be able to probe each other's IDs.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
Row Security Policies (PostgreSQL 18 documentation, section 5.9)
(PostgreSQL Global Development Group)
• Link checked 2026-11-03
Default deny, bypass for superusers, BYPASSRLS roles, owners, and referential integrity checks, and leakproof functions.
-
CREATE POLICY (PostgreSQL 18 documentation)
(PostgreSQL Global Development Group)
• Link checked 2026-11-03
USING and WITH CHECK, and use of USING as the check when WITH CHECK is omitted.
-
System Administration Functions (current_setting, set_config)
(PostgreSQL Global Development Group)
• Link checked 2026-11-03
missing_ok in current_setting and transaction-local set_config.
-
PgBouncer configuration (pool_mode)
(PgBouncer project)
• Link checked 2026-11-03
Transaction pooling releases the server connection after each transaction.
-
PGlite
(ElectricSQL)
• Link checked 2026-11-03
PostgreSQL compiled to WebAssembly, used to run this guide's lab without a database server.
-
PgBouncer features
(PgBouncer project)
• Link checked 2026-11-03
Session features that transaction pooling breaks.
Revision history
- — Replaced the Go wrapper that built SET LOCAL with string formatting, an SQL injection pattern, with set_config and a bind parameter. Removed the claim that RLS makes leaks mathematically impossible to miss, added the bypass table and leakproof planning note from the PostgreSQL 18 documentation, and added a lab run on PostgreSQL 18.6 (PGlite) that demonstrates each rule, a connection-reuse leak, and the string-formatting exploit.
- — Removed invented benchmark timings and the universal tenant_id index rule.