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. 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.