The bug report said a user had seen another company's data. That sentence starts one of the worst days you can have in SaaS.
The cause was a single reporting endpoint, added months earlier, where the query filtered by date range and status but not by tenant. Every other query in the codebase filtered correctly. This one had been written in a hurry, reviewed by someone tired, and it had been quietly returning cross-tenant rows to anyone who used that report.
What that taught me is the thing this whole topic turns on: in a multi-tenant system, isolation cannot depend on every developer remembering. One forgotten WHERE clause is a data breach, and you will eventually have a tired developer.
Three Isolation Models
The first architectural decision, and the one that is expensive to change later.
Shared database, shared schema. Every table has a tenant_id. Simplest to build and operate, cheapest per tenant, easiest to run migrations against. Also the model where a missing filter leaks data. This is what most SaaS products should start with.
Shared database, schema per tenant. Each tenant gets their own schema in the same database. Stronger separation, but migrations now run N times and get slow and error-prone past a few hundred tenants. I find this an awkward middle ground that inherits problems from both sides.
Database per tenant. Complete isolation, straightforward per-tenant backup and restore, and it satisfies enterprise contracts that demand physical separation. The cost is operational: connection management, migrations across many databases, and a much heavier per-tenant floor.
The pattern I have landed on for products that sell upmarket is a hybrid — shared schema by default, with the ability to move an individual large or regulated customer onto their own database. That means every tenant lookup goes through a resolver from day one, so the code never assumes there is only one database.
Make Isolation Structural
Given a shared schema, the goal is to make the leaking query impossible to write rather than merely discouraged.
Row-level security is the strongest tool available and it is under-used. Postgres can enforce the filter in the database, so a query that forgets the tenant clause returns nothing rather than everything.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Set once per request/transaction, from the authenticated session:
SET LOCAL app.tenant_id = '5f3c…';
Now the database is the last line of defence, not the application. That reporting endpoint would have returned an empty result and someone would have filed a normal bug instead of a security incident.
If you cannot use RLS, the fallback is a repository layer that no query bypasses — every data access goes through functions that inject the tenant scope, and raw query access is banned in review. Weaker, because it relies on the ban being enforced, but far better than nothing.
Derive the tenant from the session, never from the request. A tenant ID in a URL, body or header is a parameter a user can change. It comes from the authenticated token, always, and if a request includes a tenant ID that disagrees with the session, that is a 403 and an alert.
Write the leak test. Create two tenants in your test suite, and for every endpoint, authenticate as tenant A and attempt to read tenant B's resources. Assert 404 or 403. This is tedious to write once and it is the only test that would reliably catch what bit us.
The Noisy Neighbour Problem
The second thing that breaks multi-tenant systems, and it is a reliability issue rather than a security one. One customer imports 400,000 records, or runs a report over three years of data, and everyone else's requests queue behind it.
What actually helps:
Rate limit per tenant, not per IP. A tenant's whole organisation shares a budget. IP-based limits punish the wrong people and miss the real cause.
Separate queues by weight. Bulk imports and report generation go to a different worker pool than interactive requests, so a big job cannot starve the responsive path.
Cap query scope in code. Maximum date range, maximum result size, mandatory pagination. An unbounded query is a future incident with a customer's name on it.
Set statement timeouts. A query that runs for five minutes should be killed. Postgres will do this for you per role or per transaction.
Track cost per tenant. Query time, storage, job minutes, AI tokens. You cannot manage what you cannot attribute, and the tenant causing the trouble is rarely the one you would guess.
Details That Bite Later
Every index needs tenant_id first. An index on (created_at) is nearly useless when every query also filters by tenant. Composite indexes leading with tenant_id are the difference between a fast query and a table scan that gets slower as you grow.
Caching must be tenant-scoped. A cache key of user:profile:42 across tenants is a data leak waiting for an ID collision. Put the tenant in every key, without exception.
Background jobs carry tenant context. A job that reconstructs "which tenant is this?" from the payload will eventually get it wrong. Put the tenant ID in the job envelope and set the same session variable the web request would.
Per-tenant configuration will be asked for. Custom fields, branding, workflow rules, data retention. Design a settings mechanism early — usually a JSONB column with a schema — rather than adding columns per customer request.
Deletion is a real feature. A tenant will leave and ask for their data to be removed, and in several jurisdictions that is a legal obligation with a deadline. Knowing every table and bucket that holds their data is much easier to establish on day one than to reconstruct in year three.
Where to Start
If you are building a SaaS product now: shared schema with tenant_id everywhere, row-level security enabled from the first migration, tenant resolved from the session, composite indexes leading with tenant, and the cross-tenant leak test written before the second endpoint exists.
None of that slows you down meaningfully at the start. All of it is painful to retrofit, and one of them — the leak test — is the difference between finding the problem yourself and reading about it in a support ticket that begins with the worst sentence in SaaS.



