Most multi-tenant data leaks are not clever attacks. They are a query that forgot a filter. When tenant isolation depends on every developer remembering to add tenantId to every query, it will eventually fail.
The fix is to make the boundary part of the structure, so the safe path is the only easy path.
Resolve the tenant once
Work out the tenant from the authenticated session at the edge of the request, in one place, and attach it to the request context. Never accept a tenant id from the request body or query string.
Scope data access by construction
// The only way to reach the collection is through a tenant-scoped repository.
export function forTenant(tenantId: string) {
return {
find: (q: Filter<Job> = {}) => jobs.find({ ...q, tenantId }),
insert: (doc: NewJob) => jobs.insertOne({ ...doc, tenantId }),
update: (id: ObjectId, set: Partial<Job>) =>
jobs.updateOne({ _id: id, tenantId }, { $set: set }),
};
}Handlers receive the scoped repository, not the raw collection. A query without a tenant filter is no longer something a developer can write by accident.
Back it with indexes and tests
- Put tenantId first in compound indexes, so every scoped query is also an efficient one.
- Add an integration test that creates two tenants and asserts each can only read its own records, for every repository.
- Log the tenant id on every request, so an incident can be traced to the boundary it crossed.
Isolation that lives in the structure survives new features, new developers and late-night fixes. Isolation that lives in convention does not.
TechSea engineering team