Tenant Isolation in Practice: Enforcing Scope in the Data Layer

Choosing an isolation model is step one. Here is how we make cross-tenant data leaks structurally impossible in shared-database SaaS products.

DSDilawar Singh 2 min read 1,520 views
Tenant Isolation in Practice: Enforcing Scope in the Data Layer

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 tenantId so 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 tenantId in 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.
DS

Written by

Dilawar Singh

Chief Technology Officer, DEV3 Technologies

DEV3 Technologies has delivered web platforms, AI systems, SaaS products and mobile apps since 2025. Articles are written and reviewed by the engineers who build and operate these systems.

Keep reading

All articles