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: superusers and BYPASSRLS roles skip
RLS entirely. The security model explains why
and shows the role setup.
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.
Written and maintained by Kuldeep Pisda · source on GitHub