Solution
How to build multi-tenant SaaS where isolation cannot be forgotten
Every multi-tenant system has a tenant filter on every query. It works for years, because people are careful, and then one query written under time pressure exposes one customer to another.
The short answer
Resolve the tenant once at the edge and carry it in a request context nobody can bypass. Enforce it below the application, in row level security or a separate database, so a forgotten predicate cannot leak. Then scope background work, caches and file storage by the same identifier, because those are where isolation is usually lost.
The failure this prevents
A support ticket saying “I can see a record that is not mine”.
That sentence is different from every other bug report. It is not a defect in a feature, it is a breach, it usually carries a disclosure obligation, and the customer relationship may not survive it.
The cause is almost never exotic. It is one query missing a tenant predicate, written months ago, in a code path nobody exercises often. Everybody knew the rule. Somebody was in a hurry once.
The design goal is therefore not to be careful. It is to make forgetting impossible.
Step 1. Resolve the tenant exactly once, at the edge
Every request must be attributed to exactly one tenant, decided in one place, as early as possible.
The source can be a subdomain, a header, a claim in the token, or a path segment. What matters is that it is resolved once and then never re-derived, because two derivations eventually disagree.
request arrives
-> resolve tenant from host or token claim
-> reject with 400 if it cannot be resolved
-> attach to a request scoped context
-> nothing downstream reads the raw header again
The rule that makes this work: a request with no tenant is rejected, not defaulted. Defaulting is how a background job or a misconfigured client ends up operating against tenant one.
Verify: send a request with no tenant signal at all and confirm a clean rejection rather than a stack trace or a silent default.
Step 2. Carry it in a context nobody can bypass
The resolved tenant goes into a request scoped context, not a global, not a parameter that each function chooses whether to accept.
Passing the tenant as an argument seems explicit and therefore safer. In practice it means every new function has an opportunity to omit it, and the ones that omit it still compile and still work in testing, because in testing there is one tenant.
An ambient context per request, established at the edge and read by the data layer, removes that opportunity. The developer writing a query does not choose to apply the tenant. It is applied.
Step 3. Enforce it below the application
This is the step that separates a real guarantee from a strong convention.
On a shared schema, use row level security. The policy lives in the database, reads the tenant from a session variable set when the connection is checked out, and applies to every query on every table, including the ones written in a hurry.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint);
Now a query with no tenant predicate returns nothing rather than everything. That inversion is the whole point: the failure mode of forgetting becomes an empty result, which is noticed immediately, instead of a leak, which is noticed by a customer.
The connection checkout is the place to be careful. Set the session variable when the connection is acquired and clear it when it is returned, or a pooled connection carries one tenant’s context into another tenant’s request. That is the one bug in this design that is worse than the problem it solves, so it is worth a dedicated test.
Verify: in a test, set the session to tenant A, run a query with no filter at all, and assert that zero rows from tenant B come back.
Step 4. Scope the background work
This is where isolation is most often lost, because the request context does not exist here.
A job runs without a request. If the tenant is not part of the job payload and re-established when the worker picks it up, the worker either has no tenant or, much worse, inherits whichever one was last set on that connection.
Three rules:
- The tenant is part of the job payload, always, and the worker refuses a job without one.
- The worker establishes the same context a request would, using the same code path.
- The job’s identity key includes the tenant, or one tenant’s job deduplicates against another’s and a piece of work silently never happens.
That last one is subtle and expensive. If your job key is send-monthly-summary-2026-07, then the
first tenant to run it prevents every other tenant from running it, and the symptom is that most of
your customers did not get their summary with no error anywhere.
Verify: enqueue the same logical job for two tenants and confirm both run.
Step 5. Scope everything else that holds data
The four remaining places, in the order they are usually forgotten:
Cache keys. A key of user:42 is a cross tenant leak waiting for an id collision. Prefix every key
with the tenant. Make the cache client require it rather than trusting the caller.
File storage. Paths and object keys include the tenant, and access is checked on read, not only on listing. Signed URLs need an expiry and a tenant check at generation.
Exports and reports. These often use a separate query path built for performance, which is exactly the path most likely to bypass the ordinary data layer. Audit them specifically.
Admin tooling. Internal tools legitimately cross tenants, which is why they must be a different connection, a different role, and a named path, so that crossing tenants is a visible act rather than an accident.
Step 6. Make the cross tenant path explicit and loud
Some queries genuinely need to span tenants: platform level reporting, billing, support.
Those use a separate database role, obtained through a function whose name says what it does, and every use is logged. The danger is not that such queries exist. It is that they look identical to ordinary ones, so nobody reviewing the code notices which is which.
How to test the whole thing
Two tests are worth more than a hundred case by case assertions.
The empty result test. With row level security on, run every read path with the wrong tenant in the session and assert zero rows. Not an error. Zero rows.
The seeded neighbour test. Populate two tenants with data that is deliberately similar, including identical ids where the schema allows, then exercise the entire application as tenant A and assert that no identifier belonging to B appears in any response. Run it in CI, because this is a class of bug that returns.
What this costs
Row level security costs a small amount of query planning overhead and a real amount of care around connection pooling. Context propagation costs discipline in the data layer, once. Tenant scoped job keys cost nothing but have to be designed in, because retrofitting them means auditing every job.
Against that, the alternative is a guarantee that depends on every developer remembering a predicate on every query, forever, including the week the deadline moved.
Questions people actually ask
- Where is isolation actually lost in practice?
- Almost never in the main request path, which is well trodden. It is lost in background jobs, in cache keys, in file paths, in exports and reports, and in admin tooling. Those are the five places to audit first.
- Is row level security worth the complexity?
- Yes, if you are on a shared schema. It moves the guarantee from something a developer remembers to something the database enforces, and that is the entire difference between an invariant and a habit.
- How do you handle a query that legitimately spans tenants?
- Make it explicit and rare. A separate connection or role with a name that says what it is, used only by reporting and admin paths, and audited. The danger is not that such queries exist, it is that they look like ordinary ones.