The "Whose Data Is This?" Bug

The first time multi-tenancy bites, it's usually in code review:

TypeScript
const orders = await db.order.findMany({ where: { status: 'open' } });

It looks fine. It even passes tests. In production it returns every open order across every customer of your SaaS — including the ones who pay competitors of the customer making the request. There is no scarier 30 seconds in your career than realizing that a single missing where clause leaked tenant data.

Multi-tenancy isn't a feature you bolt on. It's a constraint that has to live in your data model, your repository layer, your auth middleware, your background workers, your logs, and your deploys.

Three Models, Three Trade-Offs

Almost every multi-tenant Node.js SaaS lands on one of three database layouts. None of them is universally correct.

  1. Shared database, shared schema. Every tenant lives in the same tables, distinguished by a tenant_id column. Cheap, simple, easy to migrate, easy to leak. Almost everyone starts here.
  2. Shared database, separate schema (Postgres). Each tenant has their own schema in one Postgres database. Stronger isolation, more migrations to run, queries route via SET search_path.
  3. Database per tenant. Each tenant has their own database (sometimes their own server). Strong isolation, easy to delete a tenant cleanly, hard to run cross-tenant analytics, expensive at low ARPU.

A useful rule of thumb: B2C and prosumer SaaS tend to live happily in the shared-schema model. Mid-market B2B starts asking for "our data is in our own database" by year two. Regulated enterprise (health, finance, EU public sector) often requires database-per-tenant in the contract.

Tenant Context Belongs In AsyncLocalStorage

Passing tenantId as a function argument through every layer is exhausting and error-prone. Node's built-in AsyncLocalStorage lets you stash request-scoped context once, then read it from anywhere in the same async chain.

TypeScript
import { AsyncLocalStorage } from 'node:async_hooks';

type Ctx = { tenantId: string; userId: string; requestId: string };
export const tenantStore = new AsyncLocalStorage<Ctx>();

export function tenantMiddleware(req, res, next) {
  const tenantId = req.headers['x-tenant-id'] ?? req.subdomains[0];
  const userId = req.user?.id ?? 'anon';
  const requestId = req.headers['x-request-id'] ?? crypto.randomUUID();
  if (!tenantId) return res.status(400).json({ error: 'missing tenant' });

  tenantStore.run({ tenantId, userId, requestId }, () => next());
}

Anywhere downstream — repositories, queue producers, loggers, metrics — you can call tenantStore.getStore() and get back a typed context. No prop-drilling, no this.tenantId, no globals to leak between requests.

A Repository That Refuses To Forget The Tenant

The cleanest defense against the "missing where clause" bug is to make tenant filtering invisible at the call site. Wrap your data access:

TypeScript
import { tenantStore } from './context';

export function tenantScopedDb(prisma) {
  return new Proxy(prisma, {
    get(target, model: string) {
      const m = target[model];
      if (!m || typeof m !== 'object') return m;
      return new Proxy(m, {
        get(modelTarget, op: string) {
          const fn = modelTarget[op];
          if (typeof fn !== 'function') return fn;
          return (args = {}) => {
            const tenantId = tenantStore.getStore()?.tenantId;
            if (!tenantId) throw new Error('tenant context required');
            return fn.call(modelTarget, {
              ...args,
              where: { ...(args.where ?? {}), tenantId },
            });
          };
        },
      });
    },
  });
}

Now db.order.findMany({ where: { status: 'open' } }) automatically scopes to the current tenant. Forgetting the where clause is no longer a leak — it's just a slightly broader query inside one tenant.

This is a defense in depth, not a substitute for code review. But the math of "one safety net catches a thousand small mistakes" is real.

Diagram comparing three multi-tenant data models: shared schema with a tenant_id column and Postgres row-level security, schema-per-tenant inside one database, and database-per-tenant with separate connection pools, alongside an AsyncLocalStorage box that propagates tenant context through middleware, repositories, queues, and logs.
Pick the isolation model on purpose, then enforce it at every layer — repository, worker, log, metric.

Postgres Row-Level Security For The Paranoid

If you're on Postgres and your shared-schema model needs another safety net, RLS lets the database itself reject cross-tenant reads. The pattern:

SQL
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Then in Node, set the session variable per request:

TypeScript
await db.$executeRawUnsafe(
  `SET LOCAL app.tenant_id = '${tenantId}'`
);

(Use SET LOCAL so it dies with the transaction. Use a parameterized helper, not string interpolation, in real code.)

RLS is brilliant when it works and confusing to debug when it doesn't. Make sure your migrations, your seeds, and your test fixtures all set the variable, or you'll spend an afternoon wondering why your app sees zero rows.

Routing: Subdomain, Path, Or Header

Three common ways to identify the tenant on the wire:

  1. Subdomain. acme.api.example.com — clean URLs, plays nicely with per-tenant TLS and cookies, requires wildcard DNS plus wildcard certs.
  2. Path prefix. api.example.com/t/acme/... — easiest to set up, ugly URLs, easy to forget the prefix in client SDKs.
  3. Header. X-Tenant-Id: acme — internal APIs love it, browsers can't cache it well, every developer forgets to send it once.

For a public SaaS I default to subdomain routing. For internal multi-tenant tooling, headers are fine. Never trust the value blindly — always cross-check against the authenticated user's tenant claim.

Background Jobs Need Tenant Context Too

A queue job is just an async chain that started somewhere else. The AsyncLocalStorage you set up for HTTP requests doesn't survive serialization through Redis. Pass the tenant explicitly in the job payload, then re-establish the context inside the worker:

TypeScript
new Worker('orders', async (job) => {
  const { tenantId, userId, payload } = job.data;
  await tenantStore.run(
    { tenantId, userId, requestId: job.id! },
    () => processOrder(payload),
  );
});

Same trick applies to scheduled tasks, webhooks, and CLI scripts. If the job has no human triggering it, pick a system tenant or refuse to run — silent global jobs in a multi-tenant system are how data leaks across customers.

Per-Tenant Rate Limits And Quotas

A noisy tenant on a shared deployment can starve everyone else. rate-limiter-flexible (Redis-backed) gives you a one-line key-by-tenant limit:

TypeScript
import { RateLimiterRedis } from 'rate-limiter-flexible';

const limiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'rl:tenant',
  points: 100,
  duration: 1,
});

export async function tenantRateLimit(req, res, next) {
  const { tenantId } = tenantStore.getStore()!;
  try {
    await limiter.consume(tenantId);
    next();
  } catch {
    res.status(429).json({ error: 'tenant rate limit' });
  }
}

Combine with per-user limits for the cases where one user inside a tenant goes wild. Track quotas separately from rate limits — quotas are billing, limits are protection.

Test Your Isolation, Don't Hope For It

Write a tenant-isolation test as the first thing in your test suite:

  1. Create two tenants with overlapping data shapes.
  2. Authenticate as tenant A.
  3. Try every list, search, and detail endpoint with IDs that belong to tenant B.
  4. Assert 404 (or 403, pick a convention) on every single one.

It's a tedious test. It is also the test that finds the bugs you are most embarrassed by in incident reviews.

A One-Sentence Mental Model

Multi-tenancy is a constraint that has to live in five places at once — schema, query, request context, queue payload, and log line — so pick the isolation model deliberately and let AsyncLocalStorage plus a scoped repository carry the rest.