Identifying the tenant, and the three isolation models
coreadvancedMulti-tenancy is one deployment serving many customers whose data must never mix. Two decisions define it. First, **how you know which tenant a request belongs to** — a subdomain, a path segment, a header, or the logged-in user's membership. Second, **where the separation lives**: one database and one schema with a `tenant_id` column on every row (shared schema), one database with a schema per tenant, or a database per tenant. They trade the same thing in the same direction — the stronger the isolation, the higher the per-tenant cost of running it.
Think of it as
Read the three models as a single dial between *cheap and dense* and *isolated and expensive*, and place your product on it using the requirements you actually have rather than the ones you can imagine. Shared schema is one row set with a discriminator column, so a thousand tenants cost one migration, one connection pool and one backup — but every single query must be filtered, and the isolation is only as good as the code that remembers to do it. Schema-per-tenant keeps one database and one connection pool while giving each tenant its own tables, so a query that forgets the filter usually finds nothing rather than someone else's data; the cost is that migrations now run N times and a schema catalogue with tens of thousands of tables becomes its own operational problem. Database-per-tenant is the strongest: separate backups, separate restores, a genuinely per-tenant blast radius, and the ability to put one customer in another region — paid for with N connection pools, N migration runs, and cross-tenant reporting that is no longer a query. The identification decision looks smaller and is not, because it determines what an attacker can influence. Anything the client supplies — a subdomain, a path segment, an `X-Tenant-Id` header — is a *claim*, and the only safe use of a claim is to check it against something the server already knows: this authenticated user's membership. Deriving the tenant from the session alone avoids that entirely but forfeits shareable per-tenant URLs. Deriving it from the URL and then verifying membership gives you both, and the verification is the load-bearing half. The order matters too — resolve the tenant before authorisation runs, so every permission check downstream is already scoped, and fail closed when no tenant can be established rather than defaulting to a first, a last, or an unfiltered view of everything.
What we're doing: Resolve the tenant from the subdomain, verify it against the user's membership, and make the result unavailable to code that runs outside a request.
- 12–14
- Naming the claim in a comment is not decoration — every subsequent line exists because this value is attacker-controlled.
- 19–24
- The verification. Membership is a row the server owns, so checking the claim against it is what converts "the URL says acme" into "this user may act as acme".
- 29–32
- 404 rather than 403. A 403 confirms the tenant exists, which turns the login page into a customer-list oracle — a real disclosure even when no data leaks.
- 35
- A `ContextVar` rather than a global. Globals are shared across threads, so under any concurrency one request would see another's tenant — the exact bug this whole section exists to prevent.
- 36–39
- Cleared in a `finally`. Workers reuse threads, and a context left set means the next request on that thread starts already scoped to someone else's tenant.
Why this works: A forged subdomain gets a 404, the tenant is fixed before any permission check runs, and no request can inherit the tenant of the one before it.
Trusting a client-supplied tenant id
Wrong
Better
What you see: Nothing — until someone changes one header value and receives a different company's data, which is a reportable breach rather than a bug.
Why: A header, a subdomain and a path segment are all supplied by the caller, so filtering by one of them filters by whatever the caller asked for. The query is *correctly scoped to the wrong tenant*, which is why it looks right in review and passes every test that uses one tenant. The claim has to be checked against something the server independently knows — membership — before it is allowed to scope anything.
- Shared DB, shared schema — one row set, a tenant_id column
- Cheapest to run — one migration, one pool, one backup
- A missing filter leaks — the query returns another tenant's rows
- Isolation is your code — so it has to be structural, not remembered
- Shared DB, schema per tenant — one connection pool, N sets of tables
- A missing filter finds nothing — search_path scopes the connection
- Migrations run N times — and a failure part-way leaves tenants on two versions
- Catalogue growth — tens of thousands of tables is its own problem
- Database per tenant — the strongest, and the most to operate
- Real blast radius — restore or move one customer alone
- Data residency is possible — this tenant's database, in their region
- N pools, N migrations — and reporting becomes an ETL job, not a query
The three models, on one dial
Together
Where the tenant claim comes from, and what makes it safe
Together
Remember: Two decisions. Identification: a subdomain, path or header is a *claim* from the client, and it is only safe once checked against the user's membership — then fail closed with a 404, because a 403 confirms which customers exist. Isolation: shared schema is cheapest and leaks if a filter is forgotten; schema-per-tenant makes a forgotten filter find nothing but multiplies migrations; database-per-tenant gives real blast-radius and residency isolation for N pools and N migration runs. Pick from requirements you have, not ones you imagine — and hold the current tenant in a `ContextVar` cleared in a `finally`, never a global.
See also: making the filter impossible to forget · per tenant limits configuration and audit · object level and resource level authorization

