Architecture
Multi-tenant from day one, before you have day two
Adding tenant isolation to a live SaaS is one of the worst jobs in software. Here's the setup that costs you almost nothing on day one and saves you a rewrite later.

Every multi-tenant horror story starts the same way. The product had one customer, so the team built it for one customer, and the tenant_id got added later — usually in a hurry, usually after the second customer signed.
The problem isn't the column. Adding a column is easy. The problem is that by then you have four hundred queries written by people who assumed there was only one tenant, and every single one of them has to be correct forever. Miss one and a garage sees another garage's customers.
Isolation belongs below your application code
The instinct is to handle it in the service layer: every query goes through a repository, every repository takes a tenant, done. That works right up until someone writes a query that doesn't go through the repository. A reporting job. A migration script. A quick fix at 11pm.
The version that actually holds is one where the database refuses to return the wrong rows even if the application asks for them. In Postgres that's row-level security.
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 connection, in middleware
SET LOCAL app.tenant_id = '...';Now a forgotten WHERE clause returns nothing instead of returning everything. The failure mode flips from silent data leak to obvious empty page, and an obvious empty page gets fixed the same afternoon.
The part everyone skips: prove it
Writing the policy feels like the work. It isn't. The work is having a test that fails loudly the day someone disables it.
We keep a test that creates two tenants, writes data as each, and then tries every read path as the wrong tenant. It's not elegant. It's a long, boring file. It has caught two genuine mistakes, both of which would have been cross-tenant exposure in production.
- 01Create tenant A and tenant B with overlapping-looking data
- 02Authenticate as A, hit every list and detail endpoint
- 03Assert B's records are absent — not filtered in the UI, absent from the response
- 04Repeat in reverse, because asymmetric bugs are real
A tenant isolation test you never watch fail is a tenant isolation test you don't have.
What about a database per tenant?
It's the strongest isolation you can get and I'd still argue against it for most products. Migrations across hundreds of databases become their own full-time problem, connection pooling gets awkward, and cross-tenant reporting — which someone will ask for — turns into a distributed query.
Shared schema with enforced row-level security has been the right trade for everything I've built. If a specific enterprise customer contractually demands physical separation, that's a per-customer deployment, not a reason to reshape the whole product.
The onboarding test
Here's how I know the architecture is holding: adding a new paying customer is a row in a table. No provisioning script, no new environment, no engineer involved.
The bits that aren't rows in a table
Row-level security covers your database. It does nothing for the other three places tenant data leaks, and those are the ones I've seen actually cause incidents.
File storage is the big one. If uploaded documents land in a bucket keyed only by a filename or a sequential ID, anyone who can guess a URL can read another tenant's invoices. Every object path needs the tenant in it, and access has to go through a signed URL your application issues after checking the request — not a public bucket with an unguessable name, which is just security through length.
Then background jobs. A queued job runs outside the request that created it, so whatever mechanism you use to set the tenant on a connection isn't there any more. The tenant has to be part of the job payload and get re-established when the worker picks it up. We had a report generator that inherited whatever tenant the worker had last touched. It never shipped, but only because the isolation test caught it.
- Storage paths namespaced by tenant, served through signed URLs
- Job payloads carry the tenant explicitly and re-establish it on pickup
- Caches keyed by tenant — a cache key collision is a data leak with good performance
- Logs and error reports scrubbed, so a stack trace doesn't carry one customer's data into another's support ticket
That third one is easy to miss. A memoised lookup keyed on user ID alone will happily serve tenant A's result to tenant B if the IDs ever overlap. Put the tenant in the key, always, even when it feels redundant.
When onboarding requires a developer, that's not a process problem. That's the architecture telling you the tenant boundary isn't real yet.