postgresql-best-practices/references/postgresql-row-level-security.md
Version 9ba96abc800a.bb1 · MIT. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.
← Return to resource and package checksum
title: "PostgreSQL Row Level Security" description: "Gotchas and anti-hallucination checklist for RLS policies and multi-tenant isolation" tags: [postgresql, rls, security, multi-tenant, policies]
Row-Level Security — Gotchas & Corrections
Models know RLS fundamentals well. This reference covers only the mistakes they make.
Version Gates
- RLS introduced: PG 9.5+
AS RESTRICTIVEpolicies (AND-style): PG 10+security_invoker = trueviews: PG 15+
Critical Gotchas
- Owner bypasses RLS — table owner ignores all policies unless you run
ALTER TABLE t FORCE ROW LEVEL SECURITY. Tests pass as owner, production fails for app roles. - Session
SETunsafe with transaction pooling — useSET LOCAL app.current_tenant = '...'inside a transaction. Session-levelSETdisappears on the next PgBouncer statement in transaction mode. USINGvsWITH CHECK—USINGfilters reads;WITH CHECKcontrols allowed writes. A policy with onlyUSINGcopies it toWITH CHECKforALLcommands, but aSELECT-only policy does NOT enable INSERT.- Permissive policies OR together — same-command permissive policies combine with OR, not AND. For AND logic, use
AS RESTRICTIVE(PG 10+). SECURITY DEFINERbypasses RLS — functions run with owner privileges and can leak cross-tenant data.- Views use owner context — pre-PG15, views always execute as the view owner, bypassing caller's RLS. Use
security_invoker = trueon PG 15+. BYPASSRLSattribute — avoid granting unless deliberate. Check with:SELECT rolname, rolbypassrls FROM pg_roles.- Enable before adding policy = immediate deny — turning on RLS before creating a policy gives non-owner roles default-deny immediately.
- Index the policy column — without an index on
tenant_id, RLS turns every request into a filtered Seq Scan. - Use
current_setting('app.tenant', true)— thetrue(missing_ok) avoids hard errors when the variable is unset; returns NULL instead.
Minimal Setup Pattern
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Per-request: set_config is transaction-safe with pooling
SELECT set_config('app.tenant_id', $1, true);
-- Verify RLS is enforced for a role
SET ROLE app_user;
SELECT * FROM orders; -- only sees own tenant rows
RESET ROLE;
Anti-Hallucination Rules
- Do NOT claim table owners obey RLS unless
FORCE ROW LEVEL SECURITYis enabled. - Do NOT claim
USINGalone controls INSERT or UPDATE acceptance. - Do NOT treat
SECURITY DEFINERas RLS-safe by default. - Do NOT assume views respect caller RLS on PG versions before
security_invoker = trueviews. - Do NOT recommend session
SETfor tenant context behind transaction pooling.