Four thousand business customers share your SaaS platform. What are your tenant isolation options, and how do you make a cross-tenant data leak structurally impossible rather than merely unlikely?
Isolation runs from a tenant_id column through separate schemas and databases to a stack per tenant, trading efficiency against blast radius. Careful filtering is not an isolation model: push the predicate below the application with row-level security and scope caches and object prefixes too.
What the interviewer is scoring
- That the isolation models are compared on blast radius and operational cost, not just on tidiness
- Does the candidate move enforcement below the application layer instead of trusting every query to filter correctly
- Whether the tenant identity is derived from the authenticated principal rather than read from the request
- Can they name the places outside the database that leak, such as caches, search indexes and object storage prefixes
- Whether connection pooling is recognised as able to carry one tenant's context into another tenant's request
Answer
The four models and what each one costs when it fails
Isolation is a spectrum, and the useful framing is not which is most secure but what happens on the worst day of each.
A shared table with a tenant discriminator column puts every tenant's rows in one table with a tenant_id. It is the cheapest to run by a wide margin: one schema, one migration, one connection pool, one cache, and a small tenant costs almost nothing. Its blast radius is the whole customer base. One missing predicate in one query leaks across every tenant simultaneously, one bad migration affects everyone, and a noisy tenant competes directly with the rest for buffer cache and IO.
Separate schemas in a shared database give each tenant its own set of tables inside one instance. The isolation is stronger in a specific and limited sense: a query with a forgotten predicate now fails or returns nothing rather than returning someone else's rows, because there is no shared table to over-select from. You still share the instance, so you still share failure modes and resource contention, and you have traded them for a migration problem — four thousand schemas means four thousand copies of every DDL change, and a partially applied migration leaves the fleet in mixed states.
A database per tenant puts a real boundary in place. Compromise of one connection reaches one tenant, backups and restores are per tenant, a tenant can be moved or given a different region, and per-tenant encryption keys start to mean something. You now pay for connection pool fan-out, per-database overhead that dominates for small tenants, and a control plane you must build — provisioning, migration orchestration with per-tenant version tracking, and monitoring across thousands of databases.
A stack per tenant, sometimes called a silo, replicates application and data tier. It is what regulated enterprise customers ask for and what your cost model hates. Reserve it as a paid tier rather than a default.
Most successful products end up with a bridged model: shared tables for the long tail, dedicated databases for the largest and most regulated accounts, and a routing layer that resolves tenant to connection so the application code is identical in both. Designing for that from the start costs one indirection — never hardcoding a connection, always resolving one per request — and it is the cheapest insurance in the design.
Careful filtering is not an isolation model
The dominant answer to this question is "every query filters on tenant id", and it is the answer being tested, because it describes a control that depends on a human being correct in every query in every code path forever. The relevant statistics are not on your side: a codebase of any size has hundreds of queries, plus reports, plus admin tooling, plus the migration script somebody wrote at 2am, plus the ORM's lazy-loaded association that generated SQL you never read. The predicate only has to be missing once, and in a shared-table model the result of it missing is not degraded output, it is a page rendering another company's data.
The structural move is to make the absence of the predicate produce nothing rather than everything. In PostgreSQL that means row-level security: enable it on the table, write a policy that compares tenant_id against a session setting, and the database applies the predicate to every statement whether or not the application remembered to.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- SET LOCAL, not SET: the value is scoped to the transaction and is gone
-- at COMMIT, so a pooled connection cannot carry it into the next request.
BEGIN;
SET LOCAL app.tenant_id = '8f21…';
SELECT * FROM invoices; -- policy applies; no WHERE tenant_id needed
COMMIT;
Two details make the difference between this working and looking like it works. First, a table's owner bypasses its policies by default, so the application role must not own the tables; create them as a migration role and connect as a restricted one, or set FORCE ROW LEVEL SECURITY, and remember that a superuser and any role with BYPASSRLS ignores policies entirely. Second, use SET LOCAL inside the transaction rather than a session-level SET. With a pooler in transaction mode, a session-scoped setting outlives the request that set it and the next borrower of that connection inherits the previous tenant's context — a leak with no bug anywhere in your query code, which is exactly the class of failure this design was meant to eliminate.
If your database has no equivalent mechanism, the fallback is to put the boundary in one place you control: a repository or data-access layer that is the only thing permitted to build a query, which injects the tenant predicate itself and which a lint rule or architecture test prevents anyone bypassing. That is weaker than the database enforcing it, and you should say so, but it is far stronger than a convention.
Where the tenant identity comes from
Enforcement is worthless if the value being enforced is attacker-controlled. The tenant identifier must be derived from the authenticated principal — a claim in a validated token, or a server-side session lookup — and never from a path parameter, query string, request body or an X-Tenant-Id header that the client supplies. An endpoint that reads the tenant from the request and then diligently filters by it has built a very thorough authorisation bypass.
The subtler version appears in cross-tenant features. A user who genuinely belongs to two tenants needs a tenant switch, and the safe shape is that switching re-issues the token after a server-side membership check, rather than the client asserting which tenant it would like to be. The same goes for internal admin tooling: support staff impersonating a tenant should go through the same policy layer with an audited, time-boxed grant, not a back door that runs as a role with BYPASSRLS.
The leaks that are not in the database
A design that gets the database right and stops there still leaks, and naming these is what distinguishes a real answer.
Cache keys must contain the tenant. user:1234 in Redis, or a memoised lookup keyed only on an entity id, will serve one tenant's object to another the moment two tenants use the same surrogate key — and if your ids are sequential integers per tenant, they collide immediately. The same applies to any in-process cache surviving a request, to HTTP caching where a shared cache must not store a tenant-specific response without varying correctly, and to CDN keys.
Object storage needs a tenant prefix and a policy or signing scheme that restricts a request to that prefix, because a signed URL generated from a client-supplied path is a directory traversal waiting to happen. Search indexes need the tenant as a mandatory filter applied by the query builder, not by the caller — and a shared index means one tenant's terms influence corpus-wide statistics, which is a small information leak and occasionally a real one. Background jobs and scheduled work run outside the request context, so the tenant must be part of the job payload and re-established before any query runs; a worker that inherits whatever context the previous job left is the same pooling bug in a different costume. Exports, webhooks, error reports and analytics events all carry payloads across a boundary where nobody is checking.
On encryption, be honest about what it buys. Per-tenant keys are worth having for key revocation, crypto-shredding on deletion and satisfying a contractual requirement. They do not prevent a cross-tenant leak in a shared application, because the application holds every key it needs and a query that returns the wrong rows will cheerfully decrypt them. Claiming otherwise is a fast way to lose credibility.
Noisy neighbours are an isolation problem too
Multitenancy questions are usually framed as confidentiality, but availability is where you will actually be paged. In a shared model one tenant's unbounded report, bulk import or accidental retry storm consumes shared connections, locks and IO, and every other tenant experiences an outage they did not cause. The controls are per-tenant rate limits and concurrency caps rather than global ones, statement timeouts and a separate connection pool for analytical work, quotas on things that grow without bound, and a queue that schedules fairly across tenants instead of first-in-first-out — because a single tenant enqueueing fifty thousand jobs must not stall everyone else's.
Isolation you can describe as a rule that every developer must follow is not isolation, it is an expectation; make the enforcement point somewhere a forgotten predicate returns zero rows instead of everyone's, and then go and check the cache keys, the object prefixes, the search filter and the background jobs, because that is where the leak will come from once the database is safe.
Likely follow-ups
- Your largest customer demands their data in their own database in their own region. How do you offer that without forking the product?
- How would you prove to an auditor that no cross-tenant read is possible, rather than asserting it?
- One tenant runs a report that saturates the shared database. What do you change?
- How do you delete a tenant completely, including from backups, caches and the search index?
Related questions
- Customers want per-project roles rather than one global role. How do you model that, and where in the request does the check happen?hardAlso on row-level-security6 min
- The operations team says there is no rule for this, they just use judgement. How do you model that?hardSame kind of round: design6 min
- Marketing want to raise the price of a plan a million subscribers are already on, and change what it includes. What has to happen in the catalogue?hardSame kind of round: design5 min
- You have been handed a scope and a budget, and the budget cannot buy the scope. What do you put in front of the customer?hardSame kind of round: design5 min
- You have six teams building one product and they keep blocking each other. How do you coordinate the dependencies, and what do you make of frameworks like SAFe?hardSame kind of round: design6 min
- How would you aggregate exposure across a portfolio so that the number can still be defended a year later?hardSame kind of round: design6 min
- How do you decide whether to build a capability in-house or buy it?mediumSame kind of round: design4 min
- How do you decide whether to use a managed service or self-host a component, and which cloud costs catch teams out?hardSame kind of round: design6 min