Row-Level Security
Row-Level Security (RLS) filters which rows each query may see or modify, evaluated per statement based on policies attached to tables.
Recipe
ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_tenant_isolation ON app.orders
FOR ALL
TO app_api
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);-- Application sets tenant per request (connection pool safe with SET LOCAL)
BEGIN;
SET LOCAL app.tenant_id = '42';
SELECT * FROM app.orders;
COMMIT;When to reach for this: Multi-tenant SaaS on a shared schema, defense-in-depth when app bugs could omit WHERE tenant_id, or regulatory separation within one database.
Working Example
CREATE TABLE app.invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
amount numeric(12,2) NOT NULL
);
ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY;
-- Read-only policy for analysts
CREATE POLICY invoices_select ON app.invoices
FOR SELECT TO app_ro
USING (tenant_id = ANY (current_setting('app.allowed_tenants')::bigint[]));
-- App write policy
CREATE POLICY invoices_write ON app.invoices
FOR INSERT TO app_api
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
CREATE POLICY invoices_update ON app.invoices
FOR UPDATE TO app_api
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);What this demonstrates:
- Separate policies per command (
SELECT,INSERT,UPDATE) and role USINGfilters existing rows;WITH CHECKvalidates new/changed rowsSET LOCALscopes GUC to transaction - safe with PgBouncer transaction pooling
Deep Dive
Policy Commands
FOR clause | Applies to |
|---|---|
ALL | SELECT, INSERT, UPDATE, DELETE |
SELECT | Read visibility |
INSERT | WITH CHECK only |
UPDATE | USING + WITH CHECK |
DELETE | USING |
ENABLE vs FORCE
ALTER TABLE t ENABLE ROW LEVEL SECURITY; -- owner bypasses unless FORCE
ALTER TABLE t FORCE ROW LEVEL SECURITY; -- owner subject to policies too- Table owner and superuser bypass RLS unless
FORCEis set. - Production multi-tenant tables should use
FORCEwhen owner is migration role only.
Multi-Tenant Patterns
-- Session variable (common with ORMs)
SET LOCAL app.tenant_id = '99';
-- JWT claim via custom GUC set in connection startup (application hook)
-- current_setting('request.jwt.claim.tenant_id', true)
-- Subquery policy for org hierarchy
USING (tenant_id IN (SELECT tenant_id FROM app.user_tenants WHERE user_id = current_user_id()))Gotchas
- Forgot SET tenant GUC - Policy evaluates NULL setting; returns zero rows or denies all. Fix: Middleware sets GUC on every request; fail closed if unset.
- Owner bypass without FORCE - Migrations run as owner see all rows in psql. Fix:
FORCE ROW LEVEL SECURITYon tenant tables. - Missing WITH CHECK on INSERT - User inserts row for another tenant. Fix: Mirror
USINGexpression inWITH CHECK. - RLS + SECURITY DEFINER views - Elevated view can leak rows. Fix: Security barrier views or avoid definer shortcuts.
- Connection pool stale GUC -
SETwithoutLOCALleaks tenant across requests. Fix: AlwaysSET LOCALinside transaction.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Schema per tenant | Hard isolation, fewer cross-tenant queries | Thousands of tenants |
| Database per tenant | Maximum isolation | High operational overhead |
App-only WHERE tenant_id | Simple single-tenant | Compliance requires DB-enforced isolation |
FAQs
RLS performance cost?
Small when policy columns are indexed. See RLS Performance page for index patterns.
Superuser and RLS?
Superuser bypasses RLS unless FORCE and even then superuser still bypasses - use non-superuser app roles.
Test RLS in CI?
Set GUC, assert row counts per tenant fixture, attempt cross-tenant INSERT expecting failure.
FOR ALL vs separate policies?
FOR ALL is concise; separate policies allow different expressions per command and role.
Related
- Bypass RLS & Superuser - who escapes policies
- RLS Performance - index policy columns
- Multi-Tenant Basics - architecture context
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+.