Where tenancy lives
The first real incident on our coworking platform wasn't a breach. It was a demo.
We were walking a prospective operator through their dashboard and the occupancy chart for their one centre included desks that belonged to somebody else. Aggregate numbers only, no names, nothing you could call a leak with a straight face. The client didn't notice. I did, and I spent the rest of that call half-listening.
The cause was boring. Every table had a tenant_id, every query was supposed to filter on it, and the endpoint behind that chart ran a grouped aggregate over a date range and didn't.
You will write a few thousand queries over the life of a product. If correctness depends on remembering something a few thousand times, you are not going to remember.
Three options, two of them ruled out quickly
Tenancy can live in the application, as a tenant_id column plus discipline. It can live in the schema, one Postgres schema per tenant. Or it can live in the database, one database or even one cluster each.
Per-database is what you do when a contract contains the words "dedicated infrastructure", or when a regulator does. Our requirement was the opposite. Onboarding a new coworking operator had to be a form somebody in ops fills out, not a ticket for me, and per-database turns customer acquisition into an infrastructure task.
Schema-per-tenant was genuinely tempting. Isolation is real, and per-tenant restore becomes trivial, which sounds unimportant until an operator deletes a workspace on a Friday evening and wants it back on Monday. The cost lands on migrations. Thirty schemas is fine. Three hundred is a twenty-minute deploy that can fail halfway and leave two versions of the truth in one database, and I couldn't convince myself we'd still be at thirty in two years.
So, shared schema. Which means the real decision isn't which of the three you pick. It's whether the isolation is something the database enforces or something the team promises each other.
RLS, and the pooling trap
alter table bookings enable row level security;
alter table bookings force row level security;
create policy tenant_isolation on bookings
using (tenant_id = current_setting('app.tenant_id', true)::uuid);
force matters. Without it the table owner, which is usually the exact role your app connects as, bypasses its own policies, and you get a green test suite that proves nothing.
Every request then sets the variable inside its transaction:
export async function withTenant<T>(
tenantId: string,
fn: (tx: Tx) => Promise<T>,
): Promise<T> {
return db.$transaction(async (tx) => {
await tx.$executeRaw`select set_config('app.tenant_id', ${tenantId}, true)`
return fn(tx)
})
}
That third argument is the local flag. The setting is scoped to the transaction and gone on commit, which is load-bearing if you run PgBouncer in transaction pooling mode. A plain SET app.tenant_id outlives the transaction, goes back into the pool along with the connection, and the next request inherits somebody else's tenant.
I've seen this filed as an RLS gotcha. It's connection pooling doing exactly what it advertises, and it will bite any per-session state you set.
Both failure modes are easier to believe when you can switch them on:
The bookings query
signed in as acme·previous request on this connection: globex
| id | tenant | desk | member |
|---|---|---|---|
| 1 | acme | A-12 | Priya S. |
| 2 | globex | B-04 | Sam O.not yours |
| 3 | acme | A-13 | Dev R. |
| 4 | globex | B-07 | Lena M.not yours |
| 5 | acme | A-20 | Arjun K. |
5 rows
Nothing is filtering
This is the grouped aggregate that shipped: the right chart, over the wrong rows. No error, no failed test — just somebody else’s desks in your occupancy numbers.
We kept the application-level filter as well. Both layers, which is unfashionable, and I'd still argue for it: the ORM scope catches the mistake in review with a readable error, RLS catches it in production with an empty result set, and the two fail for unrelated reasons.
Retrofitting was the expensive part. Auditing every query we already had, finding the three that genuinely needed cross-tenant reads, then working out that one of them didn't — it was a report somebody had written against the whole table because that was quicker than the join. Turning RLS on in week one costs an afternoon. Month five cost me a fortnight, spread thin enough that I didn't notice it going.
Where it doesn't reach
Workers have no request, so they have no tenant. Every job payload carries a tenant id and every handler opens with withTenant. The rule we settled on is that a job touching more than one tenant isn't one job, it's a fan-out that enqueues one per tenant. Retries got a lot less frightening after that.
Redis has never heard of your policies. Every key is namespaced with the tenant id, and I still went looking for exceptions twice a year for a while. The one that got us was a memoised list of amenity types, cached without a prefix on the reasoning that amenities are basically the same everywhere. They were not.
Object storage is the same story: paths prefixed by tenant, signed URLs generated only inside a tenant-scoped call. If you push documents to a search index, the filter has to exist in that engine's query language too, which is where a sensible-looking "search everything" admin feature quietly becomes a normal feature.
Migrations and the support console run as a role that bypasses policies by design. Separate role, separate policy, audit row on every cross-tenant read. If your support tool is just the app with a flag flipped, you don't have isolation, you have a habit.
Branding is data
The other half of the requirement was that adding an operator shouldn't involve me. Coworking chains have brands: their logo, their colours, their domain, their booking rules. A centre in Dubai runs a different week from one in Gurugram, because Friday means something different there.
So everything that varies is a row. Theme tokens, locale, working days, cancellation windows, tax config, which modules are switched on. The frontend reads the tenant config in the root layout and hands CSS variables down, and there is no if (tenant === 'ia-spaces') anywhere in the codebase, a rule I enforced in review with more conviction than I could always justify in the moment.
The pressure to break it never stops. Someone always has one customer with one requirement that would be four lines as a special case, and those four lines are how products end up with a customers.ts that nobody can delete. When a genuine one-off turns up, and they do, it goes behind a flag on the tenant record, so at least ops can see it and switch it off.
Custom domains were the fiddliest bit. Resolving host to tenant on every request puts a lookup on the hot path for literally everything, cached with explicit invalidation when the tenant record changes. That cache is the piece of infrastructure I've thought about most since.
The demo went well, incidentally. The operator signed. Their occupancy chart shows their desks.