Row-Level Security Tenancy
Policy templates per tenant tier turn tenant_id discipline into database-enforced isolation - a safety net when application filters fail.
Search across all documentation pages
Policy templates per tenant tier turn tenant_id discipline into database-enforced isolation - a safety net when application filters fail.
Quick-reference recipe card - copy-paste ready.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_select ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY invoices_tenant_insert ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY invoices_tenant_update ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);When to reach for this:
BEGIN;
CREATE TABLE tenants (
tenant_id uuid PRIMARY KEY,
tier text NOT NULL CHECK (tier IN ('starter', 'business', 'enterprise'))
);
CREATE TABLE files (
tenant_id uuid NOT NULL REFERENCES tenants (tenant_id),
file_id bigint GENERATED ALWAYS AS IDENTITY,
name text NOT NULL,
PRIMARY KEY (tenant_id, file_id)
);
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE files FORCE ROW LEVEL SECURITY;
-- Standard tenant isolation policy
CREATE POLICY files_tenant_isolation ON files
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- Platform support read-only bypass for enterprise tier tickets only
CREATE ROLE support_reader;
CREATE POLICY files_support_read ON files
FOR SELECT
TO support_reader
USING (
EXISTS (
SELECT 1 FROM tenants t
WHERE t.tenant_id = files.tenant_id
AND t.tier = 'enterprise'
)
);
GRANT SELECT ON files TO support_reader;
GRANT support_reader TO support_agent;
COMMIT;What this demonstrates:
FORCE ROW LEVEL SECURITY applies policies to table owners too.SELECT, INSERT, UPDATE) when needed.current_setting('app.tenant_id', true) returns NULL if unset - policy should fail closed (no rows).BYPASSRLS attribute on superuser/owner bypasses policies unless FORCE is set.| Tier | Template |
|---|---|
| Starter/Business | Strict tenant_id = app.tenant_id all commands |
| Enterprise + support | Additional read-only role policy with audit |
| Platform admin | Separate role, separate connection, full audit log |
-- Verify RLS enabled on all tenant tables
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
AND c.relname NOT LIKE 'pg_%';FORCE ROW LEVEL SECURITY on tenant data tables.pg_policies.SET app.tenant_id every request; PgBouncer DISCARD ALL.tenant_id to GUC directly; index tenant_id.| Alternative | Use When | Don't Use When |
|---|---|---|
| Application-only filtering | Prototype / internal tools | Production multi-tenant SaaS |
| Schema-per-tenant | Stronger namespace isolation | Thousands of schemas |
| Views with security_barrier | Legacy pattern | Native RLS is clearer in PG 18 |
| Citus tenant distribution | Shard-level isolation | Standard single-node Postgres |
Simple tenant_id = constant policies add negligible overhead when tenant_id is indexed. Avoid per-row subqueries in policies.
Yes - each table's policies apply. Ensure join keys cannot pair rows across tenants (composite FKs help).
Migration role may need BYPASSRLS briefly or disable RLS in maintenance window. Re-enable and test policies before traffic.
Yes. Set GUC before execute. Test with PgBouncer transaction pooling - GUC must be set inside the same transaction.
Unset app.tenant_id should match zero rows, not all rows. Test with RESET app.tenant_id in integration suite.
Insert two tenants, set GUC to tenant A, assert query cannot see tenant B rows. Negative test required per table.
Need USING for SELECT/UPDATE/DELETE and WITH CHECK for INSERT/UPDATE. Incomplete policy sets leak on some commands.
Superuser bypasses unless FORCE ROW LEVEL SECURITY. Do not run app as superuser.
Yes: USING (tenant_id = (SELECT tenant_id FROM users WHERE user_name = current_user)) - ensure indexed lookup.
Multiple permissive policies OR together for same role/command. Document combinations to avoid accidental broad access.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026