Skip to main content

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:

  1. Opens a transaction on a fresh session.
  2. Runs SET LOCAL rls.<key> = <value> for each identity key you resolved. Because the write is LOCAL, PostgreSQL scopes it to this transaction.
  3. Yields the session to your handler. Every query runs inside the transaction, so every policy predicate reads your context.
  4. On commit/rollback, PostgreSQL discards the LOCAL setting.

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:

SyncAsyncPurpose
set_rls_contextaset_rls_contextset one key
apply_rls_contextaapply_rls_contextset a mapping
clear_rls_contextaclear_rls_contextclear the context
rls_context (CM)arls_context (CM)scoped set/restore
require_rls_contextassert identity present
get_active_rls_contextread 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 handlers

FastAPI 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().