Skip to main content

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 · source on GitHub