In a multi-tenant application, the worst security incident is not an attacker breaking in. It is one customer opening a page and seeing another customer's invoices. It usually happens without any exploit at all: one query somewhere forgot to filter by tenant.
Multi-tenant SaaS is how most B2B software is built, and isolation is the promise underneath every contract. Here is where that promise breaks in practice, and the guardrails that make the breaks unlikely and quickly visible.
Where isolation breaks
In my experience, data rarely leaks through the main screens. It leaks through the paths nobody tested as a different tenant:
- A single query without the tenant filter, often in a report, an export, or a quick admin feature.
- Guessable IDs.
/invoices/1042returns invoice 1042 to anyone logged in, because the code checks that the user is signed in but not that the invoice belongs to their tenant. - Background jobs that run without a request, so the "current tenant" is empty or left over from the previous job.
- Caches keyed by record ID or URL without the tenant, so the first tenant's data is served to the second.
- File storage with predictable paths and public URLs.
- Search indexes and vector stores that hold every tenant's documents in one index and filter results afterwards, or not at all.
- Exports and emails generated by shared code that was written for one tenant and reused for another.
Choose your isolation model deliberately
There are three common models, each with real trade-offs:
- Shared database, shared tables, with a
tenant_idcolumn. Cheapest to run and simplest to operate. Isolation depends entirely on the application getting every query right, so it needs the most guardrails. - Shared database, separate schema per tenant. Stronger separation, more migrations to run, and harder cross-tenant reporting.
- A database per tenant. The strongest isolation and the easiest per-customer backup and restore, but more expensive and harder to operate at scale.
Many products use the shared model for most customers and offer a dedicated database to large customers who require it. Whatever you choose, the guardrails below still apply, because jobs, caches, files, and search indexes don't care which database model you picked.
Guardrail 1: set the tenant once, assert it everywhere
Resolve the tenant once per request, from the subdomain, the token, or the user's membership, and store it in one request-scoped place. Every query, cache key, file path, and job reads it from there. Code that needs the tenant and finds none should fail loudly, not fall back to "all tenants".
Background jobs must carry the tenant ID explicitly in their payload and restore it before doing anything else. Never rely on whatever context the worker process had last.
Guardrail 2: filter by default, opt out explicitly
Use your framework's global query scopes, or an equivalent repository layer, so every query on a tenant-owned table is filtered automatically. The rare query that truly needs all tenants, such as a super-admin report, opts out by name, in code that is easy to search for and review.
Guardrail 3: let the database enforce it too
Application filters can be bypassed by a raw query or a new developer. Add a second layer in the database:
- Composite foreign keys. If an invoice line references an invoice, make the key
(invoice_id, tenant_id). The database then refuses to link a line to another tenant's invoice, even when the code tries. - Row-level security, where your database supports it, so the database itself filters rows by the tenant set on the connection.
Defence in depth means a single missed where clause is a bug, not a breach.
Guardrail 4: test it like an attacker
For every endpoint that takes an ID, write an automated test that signs in as a user of tenant A, requests a record that belongs to tenant B, and expects 404 Not Found. Return 404 rather than 403, so the response doesn't even confirm that the record exists.
Make adding this test part of the definition of done for every new route. It is cheap to write and catches the most common leak there is.
Guardrail 5: isolate the side channels
- Caches: prefix every key with the tenant ID.
- Files: store them under a tenant prefix, keep the bucket private, and serve files through short-lived signed URLs generated after a permission check.
- Search and vector indexes: store the tenant ID on every document and apply it as a filter inside the query, before ranking, not on the results afterwards. For AI retrieval this matters twice, because a model will happily quote a document it should never have seen.
- Logs: include the tenant ID, so a support engineer can investigate one customer without reading another's data.
Guardrail 6: make support access visible
Support staff sometimes need to act as a customer. Build impersonation as a feature: time-limited, recorded in an audit log, visible to the customer if they want to see it, and unable to change security settings. "Just log in with their password" is not a support tool.
The checklist
- The tenant is resolved once per request and required, never defaulted.
- Jobs carry the tenant ID explicitly and restore it first.
- Tenant filters apply by default; cross-tenant queries opt out by name.
- Composite foreign keys, and row-level security where available.
- A cross-tenant 404 test for every route that takes an ID.
- Tenant-prefixed caches, private files with signed URLs, filtered search.
- Audited, time-limited impersonation.
Customers rarely ask how you isolate their data. They assume it. These guardrails are how you make sure the assumption stays true.