Row-Level Security Tenancy
Policy templates per tenant tier turn tenant_id discipline into database-enforced isolation - a safety net when application filters fail.
Recipe
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:
- Shared-schema multi-tenant SaaS with compliance expectations (SOC2, HIPAA readiness).
- Multiple services query the same tables with different code paths.
- You need defense-in-depth beyond ORM scoping.
Working Example
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 SECURITYapplies policies to table owners too.- Separate policies per command (
SELECT,INSERT,UPDATE) when needed. - Tier-specific support access via role-targeted policy.
Deep Dive
How It Works
- RLS appends policy expressions to every query plan as filter qualifiers.
current_setting('app.tenant_id', true)returns NULL if unset - policy should fail closed (no rows).BYPASSRLSattribute on superuser/owner bypasses policies unlessFORCEis set.- Policies are per table - every tenant table needs coverage; gaps are vulnerabilities.
Policy Templates by Tier
| 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 |
SQL Notes
-- 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_%';Gotchas
- RLS without FORCE - table owner bypasses policies in migrations. Fix:
FORCE ROW LEVEL SECURITYon tenant data tables. - Missing policy on new table - ship feature with leak. Fix: migration checklist + CI query against
pg_policies. - Stale GUC in pooled connection - wrong tenant after reuse. Fix:
SET app.tenant_idevery request; PgBouncerDISCARD ALL. - Superuser BI tools - Metabase connects as owner, sees all tenants. Fix: dedicated read-only role without bypass.
- Policy performance - complex subquery per row. Fix: compare
tenant_idto GUC directly; indextenant_id.
Alternatives
| 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 |
FAQs
Does RLS slow queries?
Simple tenant_id = constant policies add negligible overhead when tenant_id is indexed. Avoid per-row subqueries in policies.
Do JOINs respect RLS?
Yes - each table's policies apply. Ensure join keys cannot pair rows across tenants (composite FKs help).
How do migrations run with RLS?
Migration role may need BYPASSRLS briefly or disable RLS in maintenance window. Re-enable and test policies before traffic.
Can I use RLS with prepared statements?
Yes. Set GUC before execute. Test with PgBouncer transaction pooling - GUC must be set inside the same transaction.
What is fail-closed behavior?
Unset app.tenant_id should match zero rows, not all rows. Test with RESET app.tenant_id in integration suite.
How do I test cross-tenant leaks in CI?
Insert two tenants, set GUC to tenant A, assert query cannot see tenant B rows. Negative test required per table.
Are INSERT policies enough?
Need USING for SELECT/UPDATE/DELETE and WITH CHECK for INSERT/UPDATE. Incomplete policy sets leak on some commands.
Does RLS apply to superuser?
Superuser bypasses unless FORCE ROW LEVEL SECURITY. Do not run app as superuser.
Can policies reference session user?
Yes: USING (tenant_id = (SELECT tenant_id FROM users WHERE user_name = current_user)) - ensure indexed lookup.
How do tier-specific policies combine?
Multiple permissive policies OR together for same role/command. Document combinations to avoid accidental broad access.
Related
- Shared Schema + tenant_id - column and index design
- Roles & Permissions RLS - general RLS mechanics
- Multi-Tenant Best Practices - CI negative cases
- Schema-per-Tenant - when RLS is not enough
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+.