In Jujutsu Kaisen, the most formidable attack is called Domain Expansion. The principle: you create an enclosed space whose rules you write, and inside it your attacks cannot miss. The opponent isn't merely fought, they're locked in a world hostile to them by construction.
That's exactly the image I use to explain multi-tenancy to a non-technical client. Each tenant of your SaaS lives in their own domain. Inside, they see only their data, their users, their rules, their colours. They don't even know the others exist.
On Driivio, a white-label SaaS foundation for driving schools, this isolation was a commercial requirement before it was a technical one: two competing driving schools in the same town were going to use the same product. If one glimpses so much as the other's student count, the product is dead.
The three models, and the one you actually choose
There are roughly three approaches.
One database per client. Perfect isolation, insane operating cost. A hundred clients, a hundred databases to migrate, back up, monitor. Reserved for heavily regulated contexts with few high-value clients.
One schema per client. The apparently elegant compromise. In practice, migrations become a nightmare as soon as one schema diverges, and Postgres connections explode.
A shared database, discriminated by column. A tenant_id column on every table. It's the most common model, the simplest to operate — and by far the most dangerous, because isolation rests entirely on the discipline of the code.
I chose the third. But with one rule that changes everything.
The rule: isolation must not depend on the developer
The column model always fails the same way. Someone writes:
const students = await db.from("students").select("*").eq("school_id", schoolId);
A hundred times. Correctly. Then one day, at 7pm, on an urgent feature:
const students = await db.from("students").select("*");
And there it is. One driving school sees its competitor's students. That isn't incompetence, it's statistics: if isolation requires a deliberate act on every query, it will fail at the first lapse of attention.
The counter is Row Level Security. Postgres applies the filter itself, at engine level, whatever the application writes:
ALTER TABLE students ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON students
USING (school_id = current_setting('app.current_tenant')::uuid);
From then on, a SELECT * FROM students with no WHERE clause returns only the current tenant's rows. The distracted developer can no longer cause a leak. The database refuses.
The change of nature matters: you move from isolation by convention to isolation by construction. The first depends on the whole team's vigilance, forever. The second depends on a policy written once.
Setting the context, the genuinely tricky part
current_setting('app.current_tenant') has to be populated somewhere. That's where the real bugs live.
Two traps, both subtle:
Connection pooling. If you set the context on a pooled connection and release it without clearing, the next query — from another tenant — inherits the previous context. Guaranteed leak, intermittent, impossible to reproduce.
The discipline: SET LOCAL inside a transaction, never a global SET. SET LOCAL dies with the transaction, whatever happens.
Background jobs. The nightly billing job has no HTTP request, therefore no current tenant. Two options: it explicitly iterates over each tenant, setting the context each time round, or it runs with a role that bypasses RLS — and then every query has to be reviewed as security code.
I've seen a product's first leak come from a CSV export launched by a scheduled task. The web journey was flawless; the job had been written three months later by someone else.
White label isn't just a logo
"White label" sells easily and is always underestimated. What actually has to vary per tenant:
- Visual identity: logo, colours, typeface. The easy part.
- Domain:
driving-school-x.driivio.cm, or even a custom domain. TLS certificates to issue and renew automatically. - Transactional emails: sender, templates, signature. A confirmation email signed with the platform's name ruins the illusion.
- Business rules: lesson duration, rate card, pass thresholds. That's where it gets serious.
- Timezone and language, as soon as you leave one country.
The painful one is business rules. Making them configurable means building a rules engine. Not making them configurable means turning clients away. There's no right answer — only a line to draw explicitly during scoping, and to defend afterwards.
My rule of thumb: what touches appearance is configurable, what touches calculation is not — unless the need is identified at three clients or more. Otherwise you're building a programming language for your users, and maintaining it for life.
What I do differently now
The tenant_id is part of the composite primary key of every business table. Not merely an indexed column: a component of the row's identity. That makes certain join mistakes structurally impossible.
An automated test that attempts the leak. In the test suite, a dedicated case: create two tenants, insert data in each, authenticate as tenant A, and check that zero rows from tenant B surface on every table. That test runs on every commit. The day someone disables RLS on a table to debug and forgets to re-enable it, CI screams.
A public identifier distinct from the internal one. URLs expose a slug, never an auto-incrementing integer. A client who sees /schools/47 deduces there are 46 other schools, and will try /schools/46. That's human.
Logs that carry the tenant. Without it, you'd debug a client incident by rummaging through everyone's traces.
The wider current problem
Multi-tenancy has become the norm — nearly every modern B2B product is multi-tenant. And yet the ecosystem tools this need very poorly.
Most ORMs treat tenant_id as an ordinary field. Frameworks have no native notion of a current tenant. Tutorials stop at "add a tenant_id column", which is to security what "add a password" is to authentication: a start, presented as a solution.
Result: every team reinvents its isolation, with its own blind spots, and leaks are discovered in the press rather than in the tests.
If you're starting a multi-tenant SaaS today, the order that will save you the most time:
- RLS enabled on every business table, before the first feature.
- The leak test in CI, before the first feature.
- Only then, the product.
Those two days invested at the start spare you the conversation where you explain to a client that their competitor saw their data.
This foundation is the one behind Driivio. The same questions arise on the management platforms I build for the home-care sector. If you're designing a SaaS several clients will share, we can talk about it.
