Production checklist
RLS is only as strong as the way it's deployed. Work through this before you ship.
1. Connect as a non-superuser role
The one that silently breaks everything. PostgreSQL superusers and BYPASSRLS
roles ignore RLS, so if your app connects as one, every policy is a no-op.
CREATE ROLE app_user LOGIN PASSWORD '…'; -- not superuser, not BYPASSRLS
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
Verify the role your app actually uses is not a superuser:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'app_user';
-- rolsuper and rolbypassrls must both be false
2. Enable and force RLS on every protected table
ENABLE alone leaves the table owner unconstrained; migrations often run as
the owner. fastapi-rls forces RLS by default — keep it that way.
fastapi-rls audit --url "$DATABASE_URL" --policies myapp.models
audit exits non-zero on drift — run it in CI and as a post-deploy check.
3. Resolve identity from a verified principal
Your identity callable is the trust boundary. Derive tenant_id/user_id from
a validated JWT claim or a server-side session — never from an unverified
request header an attacker can set.
4. Fail closed on missing context
Absent context already returns zero rows. For belt-and-suspenders, make a missing context loud so a wiring bug surfaces in tests rather than as a silent empty response:
config = RLSConfig(require_context=True)
5. Keep the pool scrub on
reset_context_on_checkout=True (the default) installs the pool-checkin scrub as
a backstop to the SET LOCAL transaction. Leave it enabled unless you have a
specific reason not to.
6. Test with a non-superuser in CI
Your integration tests must connect as the same kind of non-superuser role as production. A superuser test connection will make cross-tenant leak tests pass even if RLS is completely broken — the most dangerous kind of green build.
7. Watch the CustomPolicy boundary
TenantPolicy/UserPolicy never interpolate free text. If you use
CustomPolicy, its expression is screened, not sandboxed — construct any
dynamic portion yourself, safely. See the
security model.
Quick verification snippet
Run this against a non-superuser connection with two tenants' data present:
# With rls.tenant_id = 1 set, this must return only tenant 1's rows,
# and inserting a row for tenant 2 must be rejected by WITH CHECK.
rows = session.scalars(select(Document)).all()
assert all(r.tenant_id == 1 for r in rows)
If that assertion ever fails, stop and re-check steps 1 and 2 — you are almost certainly on a bypassing role or RLS isn't forced.