Multi-tenancy without a database: how ContextVar isolation works in FastAPI
A deep dive into the architecture behind the B2B Financial Institution Portal
Most multi-tenant systems isolate data at the database layer — separate schemas, row-level security, or separate databases per tenant. We took a different approach: the application holds no case data at all. Isolation happens at the API credential layer.
The proxy model: zero local data
The portal doesn't have its own case database. It's a smart authenticated proxy to upstream Frappe servers that do. When Tenant A makes a request, the application loads Tenant A's API keys and uses them to call the upstream server. The upstream server — which has its own data isolation — returns only Tenant A's data.
This means we can't accidentally leak Tenant B's data to Tenant A because we never have Tenant B's data in memory during Tenant A's request. The isolation is structural, not conditional.
async def resolve_request_tenant(request: Request) -> TenantConfig:
# X-Tenant-Host → X-Forwarded-Host → Host header
host = (
request.headers.get("x-tenant-host")
or request.headers.get("x-forwarded-host")
or request.headers.get("host")
or ""
)
return get_tenant_for_host(host)ContextVar: the right tool for async tenant isolation
Python's contextvars module provides context variables scoped to the current async task. When FastAPI handles a request, the middleware loads the TenantConfig and sets it in a ContextVar. Every coroutine that runs as part of that request — no matter how deep in the call stack — can read the same TenantConfig from context.
Critically, because contextvars are task-scoped (not thread-local, not global), two simultaneous requests from different tenants cannot see each other's context. There's no locking, no risk of bleed between concurrent requests.
_current_tenant: ContextVar[TenantConfig | None] = ContextVar(
"current_tenant", default=None
)
def require_current_tenant() -> TenantConfig:
tenant = _current_tenant.get()
if tenant is None:
raise RuntimeError("Tenant context is not set")
return tenantHMAC-signed credential sync: why not environment variables?
Environment variables work fine for one tenant. For N tenants with credentials that rotate, they become a deployment problem — every credential rotation requires a redeploy.
Instead, the Dynamic Tenant Registry runs a background sync on a configurable interval. It contacts the Central Authority with a HMAC SHA-256 signature (timestamp + shared secret). The Central Authority verifies the signature and returns an encrypted payload of all active tenant credentials. The registry decrypts, validates, and hot-swaps the tenant table with no redeploy needed.
def _request_signature(timestamp: str) -> str:
message = f"{timestamp}:GET:{_central_url()}:{socket.gethostname()}"
return hmac.new(
_central_secret().encode(),
message.encode(),
hashlib.sha256
).hexdigest()Observability: tracing every request across tenants
Each request gets a UUID assigned via ContextVar in the logging middleware. This UUID appears in every log line for that request — no matter how deep in the call stack the log statement is — and is returned to the client as X-Request-ID. When a bank reports an issue, we can pull every log line for that specific request instantly.
Prometheus counters and histograms track request volume, latency percentiles, and error rates per endpoint, all scraped by a /metrics endpoint with no external platform dependency.