Multi-tenant data isolation: three ways, three price tags
The most common security flaw in a multi-tenant SaaS has nothing to do with SQL injection. The organization ID travels in a parameter the user controls, and the server never checks that it belongs to them. It’s called IDOR (insecure direct object reference), it’s fixed by checking membership on every query, and it turns up in almost every audit of a young product.
The three strategies
Before the failures, the decision that comes first. There are three ways to separate your customers’ data, and each is chosen for different reasons.
| Strategy | Isolation | Cost to run | When |
|---|---|---|---|
One database, tenant_id on every table | Logical | Low | By default. From dozens to thousands of customers |
| One schema per customer | Medium | Medium-high | A contractual requirement for separation, or very uneven customers |
| One database per customer | Strong | High | Regulated sector, or a few very large customers |
The first is right for almost everyone, and it’s what Vecinly uses. The other two get chosen when someone requires them in writing. Looking safer isn’t reason enough.
What each one costs, concretely
With tenant_id, the cost is discipline: every query has to filter, and a single one that
doesn’t is enough for one customer to see another’s data. The price is permanent vigilance.
With a schema per customer, the cost is in migrations. Changing a column stops being one operation and becomes forty, with forty chances for one to fail halfway. Two years in, some schemas have drifted out of sync, and finding them takes an afternoon.
With a database per customer, the cost is in everything else: forty backups, forty version upgrades, forty sets of credentials, forty monitoring targets. You pay for it in operations time every week, forever.
Nobody mentions this part when they recommend “a database per customer, it’s safer”.
With 40 customers, what changes in practice is whether a migration is one operation or forty.
Why a check in the interface doesn’t count
The naive approach, and the one that most often makes it into production inside a prototype:
The interface only shows users the organizations they belong to. The dropdown is well built. The menu is correct. And the endpoint that returns the data accepts whatever ID it receives.
Changing a number in the address bar takes no skill at all. It’s the first thing anyone curious tries, and the first thing any audit checks.
The rule is short: the interface decides what gets shown, the server decides what’s allowed. And “the server” means the query itself. A check at the top of the function is something someone will forget to add to endpoint number thirty.
The pattern: a filter that doesn’t depend on remembering
The real fix is to take isolation out of the hands of whoever writes each query. Two layers, and both are needed.
Layer 1 · Membership is resolved on the server
The organization ID is derived from the session and from verified membership. The parameter the client sends is ignored. If a user belongs to two organizations, the choice lives in the session, and the URL has no say.
It sounds obvious written like this. It stops being obvious when someone else writes the endpoint six months later.
Layer 2 · The policy lives in the database
In Postgres, that’s RLS (row-level security): one policy per table that filters rows by a
session variable. A query that forgets its WHERE stops returning other customers’ data and
returns zero rows instead.
It’s defense in depth at its best: the mistake can still happen, and it no longer has consequences.
The interface decides what gets shown. The server decides what gets done, and the database checks it again.
And it has a cost. The policy is evaluated per row, so indexes have to be designed with it in mind. An index that worked fine without RLS can stop being used once the policy kicks in, and the query plan changes without anyone touching the query. If you turn RLS on, review the plans of your hot queries once it’s enabled.
What still fails even with both layers
With RLS on and membership checked, these remain:
- Background jobs. They run without a user session, so they usually run under a role that bypasses the policies. It’s the most common back door.
- Reports and exports. They’re almost always written with their own, more optimized queries, often over a different connection.
- The cache. Storing a result without the organization ID in the key serves one customer’s data to another, and only intermittently, which makes it the hardest kind to diagnose.
- Sequential IDs. Not an isolation failure, but it gives information away: with
invoice/1042anyone knows how many invoices you’ve issued. Use opaque IDs. - Files. Database policies don’t cover object storage. A guessable download URL bypasses everything above.
- Email. A notification sent to the wrong recipient is a breach, even if the database is perfect.
The noisy neighbor
An isolation problem that has nothing to do with security and tends to show up later: a single customer with abnormal volume degrades everyone else.
It usually arrives without warning. One customer uploads ten thousand records at once, or sets up an integration that polls every thirty seconds, and suddenly the product is slow for the other thirty-nine, who haven’t done anything.
It’s handled with measures you add in order of effort: rate limits per organization first, then separate queues for heavy work, and finally a query budget that cuts off whatever overruns. None is needed on day one. All of them are needed sooner than people expect.
When this is over-engineering
If your product has one customer, you don’t have a multi-tenant problem yet. Setting up RLS, per-organization limits and separate queues for a product with three internal users is work that buys you nothing.
The practical threshold: when two different organizations that don’t know each other are going to have data in the same system. At that point, layer 1 is mandatory right away, because rewriting membership later touches every endpoint. Layer 2 can wait until there’s something to protect.
What can never wait is the choice of strategy. Moving from tenant_id to a schema per customer
with data already in it is a long migration with real risk. Make the choice at the start and pick
the first option unless a contract forces you into another.
Vecinly is a multi-tenant SaaS in production, with fine-grained roles, recurring billing and data from homeowners’ associations that must never see each other’s. It’s built on Scaleway, in France, and you can see it running in its case study. If you have a prototype about to get its first paying customer, custom SaaS explains how it gets taken to production.