Request context & identity
A policy is only half the story. The other half is getting the caller's identity onto the database connection safely — set for exactly one request, never leaking into the next.
The SET LOCAL + transaction model
The FastAPI session dependency does four things per request:
- Opens a transaction on a fresh session.
- Runs
SET LOCAL rls.<key> = <value>for each identity key you resolved. Because the write isLOCAL, PostgreSQL scopes it to this transaction. - Yields the session to your handler. Every query runs inside the transaction, so every policy predicate reads your context.
- On commit/rollback, PostgreSQL discards the
LOCALsetting.
The value never outlives the transaction, so it cannot bleed into the next request that reuses the pooled connection.
get_session = rls.session_dependency(identity=identity)
@app.get("/documents")
def handler(session: Session = Depends(get_session)):
# inside a transaction with rls.tenant_id already SET LOCAL
...
Defense in depth: the pool scrub
SET LOCAL inside a transaction is the primary guarantee. As a backstop,
fastapi-rls installs a pool-checkin listener that scrubs rls.* session
variables (or drops the connection) when it returns to the pool. This is the
"belt and suspenders" posture appropriate for a library whose only job is data
isolation. It is controlled by reset_context_on_checkout (on by default) — see
configuration.
Identity is immutable within a scope
Once user_id or tenant_id is set for a request, it cannot be silently
overridden. Attempting to change it raises RLSContextImmutableError.
from fastapi_rls import set_rls_context, RLSContextImmutableError
set_rls_context(executor, "tenant_id", 1)
set_rls_context(executor, "tenant_id", 2) # 🔴 raises RLSContextImmutableError
This closes a whole class of bug where a later code path "helpfully" reassigns the tenant and quietly widens what the request can see. Elevating requires an explicit, auditable system context:
from fastapi_rls import system_rls_context
with system_rls_context(executor, tenant_id=99):
... # deliberate, scoped elevation — e.g. a background admin task
The low-level context API
The session dependency is the ergonomic path, but the underlying API is public and executor-based, so it works with any driver:
| Sync | Async | Purpose |
|---|---|---|
set_rls_context | aset_rls_context | set one key |
apply_rls_context | aapply_rls_context | set a mapping |
clear_rls_context | aclear_rls_context | clear the context |
rls_context (CM) | arls_context (CM) | scoped set/restore |
require_rls_context | — | assert identity present |
get_active_rls_context | — | read the in-process mirror |
The context layer talks to the database through an injected RLSExecutor
protocol (set_config/get_config), so the core never imports a driver.
Async task isolation
The in-process mirror of the active context lives in a ContextVar written
copy-on-write. Concurrent requests — asyncio tasks or threadpool threads — never
observe each other's identity. Three interleaved requests keep three distinct
identities, and nothing leaks to the parent context.
get_active_rls_context() is not reliable inside sync route handlersFastAPI runs sync dependencies and sync route handlers in different threadpool
contexts, so the contextvar mirror set in the dependency may not be visible in
the handler. This does not affect enforcement — that rides the DB session's
SET LOCAL, and the session is passed to your handler explicitly. The rule:
a handler should read identity from its own dependency, not from
get_active_rls_context().