October DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsSlow PC?RecommendedPC slow today? Run a repair scan before it gets worseResolve common Windows issues and optimize system performance.Scan NowOctober DealsAmazon USDeal season is back - check today's better picksAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
World desk12 min

Why Request Context Becomes Infrastructure in Multi-Tenant Node.js Applications

Request context starts as a logging convenience and ends up shared by authentication, tracing, data access and queues. Here is how to build it in Node.js without mistaking propagation for tenant isolation.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Request context becomes infrastructure when more than one subsystem needs the same per-request facts and none of them should have to be handed those facts by hand. In a multi-tenant Node.js service, that usually means a correlation ID for logs, a trace for observability, an authenticated principal, and a tenant scope for data access, all of which must stay consistent from the first middleware to the last background job.

The mechanism that makes this practical is AsyncLocalStorage. The principle that keeps it from becoming dangerous is simple: propagation carries state; it does not validate or authorize that state. Putting a tenant ID into an async store makes it available everywhere. It does not make it correct, and it does not make any query, cache read or queue consumer tenant-safe. This article covers how to build the context, where it breaks, how OpenTelemetry’s context relates to it, and where isolation actually has to be enforced.

How do I share request context across async calls in Node.js?

Use AsyncLocalStorage from node:async_hooks. Node’s asynchronous context tracking documentation describes it as a way to associate state with callbacks and promise chains, so the state stays available for the lifetime of a web request or another asynchronous operation. Node documents the class as stable since v16.4.0. The documentation page current at the time of writing is labeled v26.10.0, but that is the page’s version label, not a minimum runtime requirement.

Node’s own example stores a request ID inside run() and logs it from both synchronous code and a setImmediate() callback for two concurrent HTTP requests. Each request sees its own ID without it being passed as a parameter. That is the practical value: downstream functions can read execution-scoped metadata without every signature in the call stack growing an extra argument.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The documentation also says which implementation to use:

“While you can create your own implementation on top of the node:async_hooks module, AsyncLocalStorage should be preferred as it is a performant and memory safe implementation that involves significant optimizations that are non-obvious to implement.”

That sentence supports not hand-rolling a store on top of async_hooks. It does not support any particular performance figure, and none is claimed here.

Why request context turns into infrastructure

This is an architectural inference, not a phrase from Node or OpenTelemetry. A request-scoped value starts as a convenience, typically a request ID in log lines. Then other consumers attach themselves:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • the logger reads the correlation ID and tenant for every line;
  • the tracing layer reads the active span to create children;
  • the data layer reads the tenant scope to constrain queries;
  • authorization helpers read the principal;
  • queue producers and outbound HTTP clients read context to hand it to the next process.

Once that happens, the context is a contract between independent components. Someone has to own when it is created, which fields exist, what each field’s trust level is, what happens when it is absent, and how it crosses a process boundary. Those are infrastructure questions: ownership, initialization, validation, naming, lifecycle, error behavior and propagation rules. Left informal, each module invents its own answer, and the tenant-bearing parts are exactly where an inconsistency becomes a data exposure.

What context carries and what it cannot do

The most useful mental model is that request context is a carrier of verified facts, not a policy decision. The table separates what ambient context contributes from what must be enforced elsewhere.

Concern What request context can supply What still has to enforce it
Identity A reference to the authenticated principal The authentication step that verified it, and authorization checks on each resource
Tenant scope A tenant identifier already verified against the principal’s membership Database isolation (schema, database, row-level controls or query predicates), authorization on tenant-owned resources
Logging Correlation ID and tenant label on every line Log policy: what must never be logged
Tracing Trace and span identity for correlation Trust decisions about incoming trace headers; instrumentation
Caching The tenant to include in keys Authorization before a protected cache read
Async work Tenant and origin to place on the job A trusted producer path and re-established authorization in the consumer

Everything in the right-hand column is guidance drawn from OWASP’s multi-tenant security guidance; none of it comes from the runtime itself.

Should I use AsyncLocalStorage for tenant context?

Yes, as a delivery mechanism for a tenant scope you have already verified, and no, as anything resembling a security control. It is a reasonable way to avoid threading a tenant argument through every layer, and it makes it hard for a repository function to be called without a scope if you make the accessor throw when none exists. What it cannot do is decide that the scope is legitimate or stop a query that ignores it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Establishing tenant context: order of operations

OWASP’s guidance is to establish tenant context early, bind it to server-verified identity and current tenant membership (or service authorization for machine callers), and never treat a client-supplied tenant ID as proof of authorization. A subdomain, header or route parameter can select a tenant; the server must verify that the authenticated subject is allowed to act in it.

That fixes the order. The store must be created after authentication evidence exists and before any tenant-scoped work begins:

  1. Authenticate the caller. Validate the session or token with your normal mechanism.
  2. Resolve the tenant. Read the selector (subdomain, route, header) or the tenant claim, then check it against the principal’s current membership or the service’s authorization. A stale claim in a long-lived token is not the same as current membership; decide deliberately how fresh the check must be.
  3. Fail closed. On a tenant-scoped route, a missing, malformed or unauthorized tenant ends the request with an error before any handler runs. Public or intentionally global routes simply do not get a tenant; do not invent one.
  4. Open the store with run(). Everything downstream executes inside the callback.
  5. Treat cross-tenant administration as its own path. It needs separate authorization and auditing, and should be an explicit, visibly different context rather than a tenant ID that someone sets to a different value.

The following is an illustrative sketch of the shape, not a tested implementation. The functions authenticate and resolveTenantForPrincipal stand for your own code.

import { AsyncLocalStorage } from 'node:async_hooks';

const storage = new AsyncLocalStorage();

export function runWithContext(ctx, fn) {
  return storage.run(Object.freeze({ ...ctx }), fn);
}

export function requestContext() {
  const ctx = storage.getStore();
  if (!ctx) throw new Error('No request context: called outside a request scope');
  return ctx;
}

// Express-style middleware, mounted after authentication
app.use(authenticate);
app.use(async (req, res, next) => {
  try {
    const tenantId = await resolveTenantForPrincipal(
      req.auth,
      req.get('x-tenant-id') // a selector, not proof
    );
    if (!tenantId) return res.status(403).end();
    runWithContext(
      { requestId: req.id, principalId: req.auth.sub, tenantId },
      next
    );
  } catch (err) {
    next(err);
  }
});

Calling next inside the run() callback is what scopes the rest of the handler chain. Verify this against the middleware framework you use, since frameworks differ in how they invoke later handlers.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Designing the store itself

Keep the schema small, typed and owned by one module. A reasonable set is a correlation ID, an authenticated principal reference, a verified tenant identifier and minimal request metadata. Specific guidance:

  • No secrets. Do not store bearer tokens, API keys or unnecessary personal data in a general-purpose ambient context. Anything in it can end up in logs, error reports and span attributes.
  • Mediate access. Expose a small accessor (like requestContext() above) instead of the raw storage object, so arbitrary modules cannot mutate shared state. OpenTelemetry’s context specification takes a similar line, recommending opaque unique keys and mediated access.
  • Prefer immutability. The same specification says: “A Context MUST be immutable, and its write operations MUST result in the creation of a new Context containing the original values and the specified values updated.” That is a specification for OpenTelemetry’s own Context, not a rule for AsyncLocalStorage, but it is a sound discipline for a tenant store: freeze it and derive a new scope for a changed value.
  • Label trust. A tenant ID verified against membership and a raw header value are different things. Only the verified one belongs in the store under the name tenantId.
  • Prefer run() to enterWith(). run(store, callback) bounds the scope visibly. enterWith() changes the store for the remainder of the current synchronous execution and its asynchronous continuations, which makes the boundary harder to see. Check Node’s documentation for the semantics in your runtime version before using it.

How do I prevent cross-tenant data leaks in a Node.js app?

By enforcing scope at every resource that holds tenant data, independently of whether the caller remembered to look at the context. OWASP checks authorization at every path through which tenant-owned resources are reached and recommends testing negative cross-tenant cases. The context makes the verified scope available to those controls; it is not a substitute for them.

Choosing a database isolation model

OWASP describes separate databases, separate schemas, shared tables with row-level controls, and hybrids. It does not name a universal winner. How well each works depends on actual enforcement: credentials, roles, policy coverage and operational setup. The comparison axes that matter:

Axis What to ask
Security boundary Which component enforces separation, and which credentials or privileged roles can bypass it?
Operational complexity How hard are provisioning, migrations, connection pooling, backups and tenant offboarding?
Failure impact What happens if a predicate is missing, a policy is misconfigured or a cache key omits the tenant?
Workload and compliance fit What is the data classification, any regulatory constraint, and the resource profile of your biggest tenants?
Verification burden Can you inventory every control and test cross-tenant denial continuously?

None of the designs is secure on its own account. Separate databases still fail if one shared credential can reach all of them. Row-level security still fails if the application role bypasses it or the policy does not cover a table.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Pooled connections and row-level security

For shared PostgreSQL tables protected by row-level security that reads a tenant setting, OWASP recommends transaction-local state, re-established for every transaction. The hazard is connection reuse: a pooled connection carries session state from one request to the next, so a tenant setting that survives the transaction can apply to a different tenant’s later request. The context in AsyncLocalStorage is per request; the connection is shared, so the two lifecycles do not line up unless you make them.

A sketch of the pattern, again illustrative: open a transaction, set the tenant for that transaction only, run the work, commit. In PostgreSQL, set_config(name, value, true) (or SET LOCAL) scopes the setting to the current transaction.

export async function withTenantTx(pool, work) {
  const { tenantId } = requestContext();
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await work(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Funnelling all tenant-scoped queries through one helper like this also makes the “no context” failure explicit, because requestContext() throws rather than silently running unscoped.

Caches

Include tenant identity in cache keys whenever a value, or an authorization result, varies by tenant. Treat that as defense in depth: separate keys stop one tenant being served another’s entry, but they do not replace an authorization check before a protected cache read.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Queues and background work

AsyncLocalStorage context does not travel through a message broker, and a consumer should not trust it to. OWASP’s approach is to classify each kind of job as tenant-scoped, global or explicitly cross-tenant, bind the tenant scope through a trusted producer path, and re-establish authorization at the consumer. In practice the producer copies the verified tenant ID into the job payload or metadata; the worker reads it, validates that the job type is allowed to act for that tenant, and then opens a fresh run() scope before executing.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Why is AsyncLocalStorage context undefined after await?

Node says AsyncLocalStorage works without issues in most cases and that context loss occurs in rare situations, so do not assume it is a routine problem. When getStore() returns undefined where you expect a value, work through these in order:

  1. Check you are inside a run() callback at all. The most common cause of an empty store is code executing before or outside the scope: module initialization, a timer started at startup, an event emitter whose listener was registered elsewhere, or a handler mounted before the context middleware.
  2. Locate the exact operation where it disappears. Log getStore() before and after each suspected call. Node’s guidance is to check the suspected calls rather than guess.
  3. Look for callback-based APIs. Node notes these can be promisified, which often keeps the context intact.
  4. Use AsyncResource for custom callback work. If a library queues callbacks itself (a custom pool or queue, for example), AsyncResource can associate the callback with the right execution context.
  5. Consider custom thenables. Node’s documentation lists these among the situations where context can be lost.

The deeper lesson is the failure mode. Because the store is empty rather than wrong, the accessor should throw on a missing context in tenant-scoped code. A silent fallback to “no tenant” is how a context bug becomes a leak.

Does OpenTelemetry context carry my tenant ID?

Not unless you put it there, and it should not be your tenant authority if you do. OpenTelemetry’s context is related to your request context but is a separate system with a different purpose.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Active context and the context manager

The OpenTelemetry JavaScript Context API stores the active span so that components creating child spans can find the parent. The active context depends on a configured context manager. The JavaScript documentation is explicit: “Without one, api.context.active() will ALWAYS return the ROOT_CONTEXT.” In Node, a context manager can use async_hooks or AsyncLocalStorage for the execution propagation, which is why the two get conflated. Having your own tenant store and OpenTelemetry’s context manager in the same process is normal; they are separate stores.

Propagation between services

OpenTelemetry propagation moves context between services by injecting values into a carrier (such as HTTP headers) on the sender and extracting them at the receiver. Supported instrumentation handles most common cases automatically; manual propagation is for gaps where no matching instrumentation exists or you need behavior it does not provide. The default propagator uses W3C TraceContext headers.

Trace identity is not tenant identity

A trace ID establishes causal correlation across spans and services. It says nothing about whether the caller belongs to a given tenant. Likewise a tenant-id header that travels beside traceparent or baggage inherits no trust from it. OpenTelemetry advises caution with externally supplied context and limiting sensitive internal information sent to untrusted services. Baggage in particular must never carry credentials, API keys or personal data, because it can be forwarded to downstream services and observed there.

If you want tenant labels on spans, set them as attributes from your verified context inside the service. For service-to-service calls, the receiving service should derive tenant scope from its own authentication of the caller, and treat any propagated tenant hint as a selector to be checked, exactly like a client header.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A testing checklist

This is guidance synthesized from Node’s and OWASP’s documentation, not a record of tests run against any particular system. Isolation is something you verify, because a missed predicate does not fail loudly.

  • Async boundaries. Assert that the store survives promise chains, await, timers and any callback-based library you use, and that it is absent where it should be (module startup, unscoped workers).
  • Concurrency. Fire concurrent requests for two tenants with deliberately interleaved delays; assert each sees only its own context and data.
  • Client-supplied tenant. Send a valid user’s token with another tenant’s identifier in the header or route; expect denial, not an empty result.
  • Missing context. Call a tenant-scoped repository function outside a request scope and expect an error.
  • Connection reuse. With a small pool, run tenant A’s transaction, then tenant B’s on the same connection; confirm no tenant setting leaks. Use the actual request database role, and confirm that role cannot bypass row security.
  • Positive and negative cases. Test successful same-tenant operations as well as denied cross-tenant ones, on every path to tenant-owned resources.
  • Caches. Confirm keys vary by tenant and a protected read still passes authorization.
  • Consumers. Enqueue a job claiming a tenant the producer was not authorized for; the consumer should reject it.

Where this leaves the design

Build one small, owned, immutable request context; populate it only after authentication and tenant verification; make its accessor fail when it is empty; let OpenTelemetry handle trace propagation with its own context manager; and enforce tenant scope again at the database, cache, storage and queue layers. The context is the shared infrastructure. The enforcement at each resource is the isolation.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Crashes, No Sound, or Screen Glitches?Free driver scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.