Shared tables with a tenant ID are the most cost-effective isolation model for most SaaS products. They also put the whole customer base one forgotten where clause away from a data leak. The fix is not more careful developers. It is a data layer where an unscoped query is hard to write and easy to detect.
Resolve the tenant once, at the edge
Determine the tenant from the authenticated session (never from a request body or header the client controls), then carry it through the request with AsyncLocalStorage.
import { AsyncLocalStorage } from 'node:async_hooks';
type TenantContext = { tenantId: string; userId: string };
const store = new AsyncLocalStorage<TenantContext>();
export const withTenant = <T>(ctx: TenantContext, fn: () => Promise<T>) => store.run(ctx, fn);
export function currentTenant(): string {
const ctx = store.getStore();
if (!ctx) throw new Error('No tenant context — refusing to run an unscoped query');
return ctx.tenantId;
}
Scope every query automatically
Handlers should never pass tenantId by hand. A Mongoose plugin (or an ORM middleware) adds it to every read and write, and fails loudly when there is no context.
export function tenantScoped(schema: Schema) {
schema.add({ tenantId: { type: String, required: true, index: true } });
const scope = function (this: Query<unknown, unknown>) {
this.where({ tenantId: currentTenant() });
};
for (const op of ['find', 'findOne', 'countDocuments', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany', 'findOneAndUpdate'] as const) schema.pre(op, scope);
schema.pre('save', function () {
this.set('tenantId', this.get('tenantId') ?? currentTenant());
});
schema.pre('aggregate', function () {
this.pipeline().unshift({ $match: { tenantId: currentTenant() } });
});
}
Platform-level jobs that legitimately cross tenants (billing runs, analytics roll-ups) use a separate, explicitly named entry point that is reviewed and audited.
Indexes and noisy neighbours
- Prefix compound indexes with
tenantIdso each tenant's queries touch a contiguous slice of the index. - Rate-limit per tenant as well as per user, so one customer's import cannot starve everyone else.
- Carry
tenantIdin every background job payload and re-enter the tenant context in the worker.
Test for leaks, continuously
We add a cross-tenant test suite to every multi-tenant project: create two tenants with identical data, then call every read endpoint as tenant A and assert that no identifier from tenant B appears in the response. It is cheap to write, runs in CI, and has caught real regressions.
Field note: the most common leak we find in audits is not a missing filter in a handler — it is an aggregation pipeline or a raw driver call that bypasses the ORM entirely.
Key takeaways
- Derive the tenant from the session, once, and carry it implicitly.
- Enforce scope in the data layer so handlers cannot forget it.
- Make cross-tenant operations explicit, rare and audited.
- Index and rate-limit with the tenant in mind.
- Keep an automated cross-tenant leak test in CI.