javascript 153 lines · 4 tabs

Per-Tenant Request Context in Express With AsyncLocalStorage and a Scoped Repository

Shared by codesnips Sep 2026
4 tabs
const { AsyncLocalStorage } = require('async_hooks');

const storage = new AsyncLocalStorage();

function runWithTenant(tenantId, userId, callback) {
  return storage.run({ tenantId, userId }, callback);
}

function getTenantId() {
  const store = storage.getStore();
  return store ? store.tenantId : null;
}

function requireTenantId() {
  const tenantId = getTenantId();
  if (!tenantId) {
    throw new Error('No tenant context: request ran outside tenantResolver');
  }
  return tenantId;
}

function getUserId() {
  const store = storage.getStore();
  return store ? store.userId : null;
}

module.exports = { runWithTenant, getTenantId, requireTenantId, getUserId };
4 files · javascript Explain with highlit

Multi-tenant SaaS backends must guarantee that a request for tenant A can never read or write tenant B's rows. Passing a tenantId argument through every function is fragile: one forgotten parameter becomes a data leak. This snippet enforces tenancy at two layers — a resolver middleware that establishes an ambient request context, and a repository that refuses to run any query without it.

In tenantContext.js, Node's AsyncLocalStorage holds a per-request store that survives across await boundaries and nested callbacks. runWithTenant opens a store for the lifetime of the request, and getTenantId reads it back later without threading it through call signatures. requireTenantId throws when the context is missing, turning a silent bug into a loud failure at the exact point a query would have leaked.

tenantResolver.js is the Express middleware that populates that context. It extracts the tenant from a signed JWT claim and cross-checks it against the X-Tenant-Id header, rejecting mismatches with a 403 to defeat token replay across tenants. Crucially it calls next() inside runWithTenant, so every downstream handler, service, and repository executes within the store. A subtle pitfall handled here: the callback form of runWithTenant must wrap next rather than returning before the async chain settles, otherwise the context would unwind too early.

ScopedRepository.js is where the guarantee is cashed in. Every method calls requireTenantId() and injects a tenant_id = $1 predicate into the SQL, so callers physically cannot query outside their tenant. insert stamps the tenant id onto new rows, and update/delete scope their WHERE clauses the same way. Because the id comes from the ambient context and not from method arguments, application code stays clean while isolation is centralized and auditable.

orders.routes.js shows the payoff: handlers read req and body data but never mention tenantId — the repository resolves it implicitly. The trade-off is that the pattern relies on the middleware always running first; mounting the repository behind an unauthenticated route would throw rather than leak, which is the safe failure mode. For defense in depth this pairs well with Postgres row-level security, but the application-layer scope alone eliminates the most common class of cross-tenant bugs.


Related snips

Share this code

Here's the card — post it anywhere.

Per-Tenant Request Context in Express With AsyncLocalStorage and a Scoped Repository — share card
Link copied