What is tenant context propagation in multi-tenant microservices?
Tenant context is the claim "this request acts on behalf of tenant X," carried with a unit of work as it moves between services. In multi-tenant microservices that claim decides whose rows a query returns, which shard a request routes to, and whose invoice a usage event lands on. Treat it as a security-relevant identity claim, not a convenience header. If any hop accepts a tenant ID it cannot trace back to an authenticated principal, a caller who can set a header can read another customer's data.
Teams propagate tenant context for four reasons: data isolation, per-tenant routing (dedicated pools, regional placement, rate limits), metering and billing, and debugging, where filtering logs and traces by tenant is often the fastest way to scope an incident. The owners are usually backend and platform engineers running shared-infrastructure SaaS, where one deployment of each service handles many tenants.
That is where the problem applies: shared services and shared data stores. If you run single-tenant deployments or a separate stack per tenant, network, account, or database boundaries already provide isolation. Heavy propagation machinery adds failure modes there without adding protection. A tenant tag for observability is usually enough.
[Client] --token--> | GATEWAY | (1) authenticate; derive tenant from verified claims
|
v
[Service A] --> | Service B | (2) re-verify signed context; never trust a bare header
|
v
[Message Queue] (3) tenant in message envelope; consumer re-establishes it
|
v
[Worker] --> | DATA LAYER | (4) enforce tenant filter; reject if context missing
+--------+ token +------------------------------------------------+
| Client |--------->| API GATEWAY |
+--------+ | [TB1] verify token; derive tenant from claims |
| drop any client-supplied tenant header |
+------------------------------------------------+
v signed tenant context
+------------------------+ +------------------------------+
| Service A |--signed ctx->| Service B |
| [TB2] verify signature | | [TB2] verify signature again |
+------------------------+ +------------------------------+
v publish; tenant + signature in envelope
+------------------------------------------------+
| MESSAGE QUEUE |
| envelope: tenant_id | signature | payload |
+------------------------------------------------+
v consume
+------------------------------------------------+
| Worker |
| [TB3] verify envelope; re-establish tenant |
| context before doing any work |
+------------------------------------------------+
v query scoped to verified tenant
+------------------------------------------------+
| DATA LAYER |
| [TB4] enforce tenant filter on every query |
| reject if tenant context is missing |
+------------------------------------------------+
[TBn] = trust boundary: tenant identity established or re-verified
Tenant identity is derived from verified token claims at the gateway, then re-verified at each service hop, re-established by queue consumers from a signed message envelope, and enforced again at the data layer.
Where should tenant identity originate, and how do you verify it at the edge?
Tenant identity should be resolved exactly once, at the gateway or ingress, from something the caller cannot forge. There are three reliable sources: a claim in a signed access token, the client identity in an mTLS certificate, or a subdomain looked up in a tenant registry that you control. Never accept a value the client merely asserts. A query parameter or an X-Tenant-ID header is an assertion, not an identity.
This makes the gateway's first job subtractive. Strip or reject every inbound tenant header before the request enters the mesh. If you forward a client-supplied header alongside your own, a downstream service that reads the wrong one turns a header injection into cross-tenant access. Rejecting with a 400 is louder than silently stripping, and it helps you catch misbehaving clients. The middleware example that follows takes this approach: it validates the token, extracts the tenant claim, deletes any incoming tenant header, and then sets a single canonical one.
Past the edge, you have to decide how far that trust extends:
- Verify a signed token at every hop. Each service checks the signature itself, so one compromised service cannot mint tenants. The costs are verification latency, distributing and rotating keys to every service, and tokens that expire partway through long-running jobs.
- Trust an internal header after edge validation. This is simpler and faster. The weakness is that any buggy or compromised internal service can rewrite the tenant, and nothing downstream will notice.
Many teams start with the second option and add per-hop verification only on services that handle sensitive data.
import type { Request, Response, NextFunction } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Issuer, audience and key set URL come from gateway config (placeholders here).
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL!));
const ISSUER = process.env.TOKEN_ISSUER!;
const AUDIENCE = 'api-gateway';
const TENANT_HEADER = 'x-tenant-id'; // Node lowercases incoming header names
const TENANT_FORMAT = /^[a-z0-9-]{1,64}$/;
export async function tenantContext(req: Request, res: Response, next: NextFunction) {
// Remove any client-supplied tenant header first. Only the gateway sets it.
delete req.headers[TENANT_HEADER];
const [scheme, token] = (req.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) return res.status(401).end();
let payload;
try {
// Checks signature, expiry, issuer and audience before trusting any claim.
({ payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: AUDIENCE }));
} catch {
return res.status(401).end();
}
// The tenant comes only from the signed claim, never from the request itself.
const tenant = payload.tenant_id;
if (typeof tenant !== 'string' || !TENANT_FORMAT.test(tenant)) {
return res.status(403).end();
}
// Set one canonical header for the services behind the gateway.
req.headers[TENANT_HEADER] = tenant;
next();
}
Express gateway middleware that deletes any inbound X-Tenant-ID header, verifies the bearer token with jose, and then sets a single canonical tenant header from the signed tenant_id claim.
Is W3C Baggage enough to carry tenant identity between services?
Three channels can carry tenant identity between services, and they offer different guarantees. A forwarded or exchanged token, such as a signed JWT passed through or minted per hop by a token exchange, can be verified by every service that receives it. A custom internal header like X-Tenant-ID is cheap and easy to read, but it is a plain string, and the receiving service has to trust whoever set it. W3C Baggage is a standard baggage header of key-value pairs. OpenTelemetry SDKs propagate it automatically.
Baggage is convenient, but it is the weakest choice for identity:
- It is unsigned. Nothing ties an entry to its origin.
- Any hop can change it. Any service or proxy on the path can add, rewrite, or drop entries.
- It has a size budget. Every outbound request carries it, and proxies and servers cap header sizes. Teams that keep adding entries eventually see truncation or rejected requests.
- It can leak. Instrumented HTTP clients attach baggage to every outbound call, including calls to third-party APIs, unless you strip it at the egress boundary.
| Channel | Verifiability | Tamper resistance | Tooling support | Leak risk | Appropriate use |
|---|---|---|---|---|---|
| Forwarded/exchanged token | Signature checked per hop | High | Auth libraries, gateways | Moderate; scoped by audience | Authorization decisions |
| Custom internal header | None | Low | Manual middleware | Moderate | Trusted-mesh routing, with edge stripping |
| W3C Baggage | None | None | Built into tracing SDKs | High without egress filtering | Logs, metric labels, trace correlation |
Use baggage to tag telemetry with the tenant so that you can filter traces and break down metrics per tenant. Do not use it for authorization. Any access decision should come from a verified credential.
How do you keep tenant context intact inside a service and on outbound calls?
Once the edge has verified the tenant, each service needs somewhere to keep it for the life of the request. In Go this is context.Context, passed explicitly as the first argument. Java uses thread-locals or, on newer runtimes, scoped values. Node has AsyncLocalStorage, and .NET has AsyncLocal<T>. Both of those flow a value through async continuations without changing any function signatures.
Where context goes missing
Ambient storage is tied to an execution path, so it is lost when work moves to a different path. Common cases:
- A task is submitted to a thread pool or executor that does not copy thread-locals.
- A coroutine is launched without the context element.
- A reactive pipeline switches schedulers.
- A hand-rolled goroutine or worker is started with
context.Background()instead of the request's context.
In each case the downstream code finds no tenant and, worse, may silently fall back to a default.
This is the trade-off. Implicit ambient context removes boilerplate, but losing it produces no error. Explicit context parameters are more verbose. When one is dropped, though, the omission shows up in the type signature or during code review.
One interceptor for every outbound call
Attach tenant context in a single client interceptor, such as a gRPC client interceptor or an HTTP transport wrapper. It reads the request-scoped value and sets the header on every outgoing call. If the value is absent, it fails the call. Handlers never copy headers by hand, which removes a whole class of omissions.
The cost is ownership. A shared library keeps this behavior consistent, but you must maintain one in every language you run. If two implementations drift, for example one rejects a missing tenant and another forwards an empty one, that difference is an isolation risk in its own right.
import { AsyncLocalStorage } from "node:async_hooks";
type TenantContext = { tenantId: string };
// Request-scoped storage. It flows through awaits and promise continuations
// without adding a parameter to every function signature.
export const tenantStore = new AsyncLocalStorage<TenantContext>();
// Called once by inbound middleware, after the edge has verified the tenant.
export function runWithTenant<T>(tenantId: string, fn: () => T): T {
return tenantStore.run({ tenantId }, fn);
}
// The single outbound wrapper. Service clients call this instead of raw fetch,
// so handlers never copy tenant headers by hand.
export async function tenantFetch(
input: string | URL,
init: RequestInit = {}
): Promise<Response> {
const ctx = tenantStore.getStore();
if (!ctx || !ctx.tenantId) {
// Fail closed. Do not forward an empty tenant or fall back to a default.
// This branch is how lost context (for example, work started outside
// runWithTenant) becomes visible instead of silent.
throw new Error("tenant context missing on outbound call");
}
const headers = new Headers(init.headers);
// Overwrite any caller-supplied value. Only the verified context is trusted.
headers.set("x-tenant-id", ctx.tenantId);
return fetch(input, { ...init, headers });
}
// Usage inside a handler: no header handling at the call site.
// await runWithTenant("tenant-acme", async () => {
// const res = await tenantFetch("");
// });
A Node.js AsyncLocalStorage store holds the verified tenant for the request, and one fetch wrapper attaches it to every outbound call, failing closed when the context has been lost.
How do you propagate tenant context across queues, events, and batch jobs?
An HTTP call keeps the caller and callee connected for the length of the request. A message does not. The producer may be gone by the time a consumer reads it, so tenant identity has to travel inside the message. Put it in message metadata or headers rather than the payload, so infrastructure can read and route on it without parsing business data. Because anyone with publish rights can write any header, wrap the tenant ID, issuer, and issue time in a signed envelope. Consumers must verify the signature before trusting it and reject messages that fail.
Restore and clear context per unit of work
Consumers, scheduled jobs, and fan-out workers often handle many tenants in a single process. Set tenant context for each message or job item, never once at startup. Context left over from one tenant can carry into the next through thread-locals, pooled connections with session variables, or cached clients.
A safe consumer follows these steps for every message:
- Read the envelope and verify its signature.
- Set tenant context.
- Process the message.
- Clear tenant context in a
finally-style block, so it is cleared on success, error, and retry alike.
For batch jobs, use the same pattern for each tenant partition. A job that loops over tenants should never run with an ambient tenant set.
Credentials outliving tokens
A user token forwarded into a job can expire before a long export or reindex finishes. Do not stretch token lifetimes to fit the longest job. Instead, give the worker a service credential scoped to the single tenant named in the verified envelope. That credential should be short-lived, renewable, and unable to reach any other tenant's data.
import base64, hashlib, hmac, json, time
from contextvars import ContextVar
current_tenant: ContextVar[str | None] = ContextVar("current_tenant", default=None)
SIGNING_KEY = b"example-signing-key-not-real" # load from a secret store in practice
TRUSTED_ISSUERS = {"orders-service"}
MAX_AGE_SECONDS = 3600
class InvalidEnvelope(Exception):
pass
def verify_envelope(headers: dict) -> dict:
# The envelope travels in headers so the business payload never has to be parsed for identity.
raw = headers["tenant-envelope"].encode()
expected = hmac.new(SIGNING_KEY, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, headers.get("tenant-sig", "")):
raise InvalidEnvelope("bad signature")
env = json.loads(base64.urlsafe_b64decode(raw)) # {tenant_id, iss, iat}
if env["iss"] not in TRUSTED_ISSUERS or time.time() - env["iat"] > MAX_AGE_SECONDS:
raise InvalidEnvelope("untrusted issuer or stale envelope")
return env
def handle(message, process, dead_letter):
# Called once per message. Context is never set at process startup.
try:
env = verify_envelope(message.headers)
except (KeyError, TypeError, ValueError, InvalidEnvelope):
dead_letter(message) # reject outright; never fall back to a default tenant
return
token = current_tenant.set(env["tenant_id"])
try:
process(message.body) # downstream code reads current_tenant.get()
finally:
# Runs on success, exception, and before any retry, so the next
# message on this worker starts with no tenant set.
current_tenant.reset(token)
A Python consumer that verifies an HMAC-signed tenant envelope from message headers, sets tenant context with a ContextVar for that single message, and always resets it in a finally block.
How should the data layer enforce tenant isolation and fail closed?
Every layer above this one can contain a bug. The data layer is where that bug turns into a leak, so it should refuse to run unscoped work by itself.
Tenant-scoped repositories and query builders read the tenant from request context and add the tenant_id predicate themselves, so handlers never write it. Row-level security moves that predicate into the database. A PostgreSQL policy such as USING (tenant_id = current_setting('app.tenant_id', true)::uuid) matches no rows when the variable is unset, so a missing tenant fails closed. Table owners bypass RLS unless you enable FORCE ROW LEVEL SECURITY. Per-tenant schemas or databases separate tenants physically. Cache key prefixes such as t:{tenant}: stop one tenant's cached value from being served to another.
| Option | Isolation | Ops overhead | Connection growth | Per-tenant restore | Migrations / cross-tenant reporting |
|---|---|---|---|---|---|
| Scoped repositories | Weak: relies on code paths | Low | None | Hard | One migration; reporting easy |
| Row-level security | Strong within a database | Moderate | None | Hard | One migration; reporting needs a bypass role |
| Schema or database per tenant | Strongest | High | Scales with tenants | Easy | Migrations repeat N times; reporting needs fan-out |
| Cache prefixes | Logical only | Low | None | n/a | Flush per prefix |
Connection pooling can undo RLS. A session-level SET stays on the pooled connection, and the next request that checks it out inherits the previous tenant. Use SET LOCAL inside the transaction, or reset the variable on every checkout.
Reject any request whose tenant is missing or does not match the verified identity. Never fall back to a default tenant. Run legitimate cross-tenant work, such as billing or support tooling, under a separate privileged role that is explicitly requested and audit-logged.
When should you avoid heavyweight tenant context propagation?
The full pattern of signed context, per-hop verification, signed message envelopes, and RLS costs real effort. It is not always worth paying for.
- Single-tenant or siloed deployments. If each tenant gets its own stack, account, or database, the infrastructure boundary already does the isolation. Every request a service sees belongs to the same tenant, so propagation only adds code that can fail. Tag telemetry with the tenant and stop there.
- Few, large tenants with strict compliance needs. When a handful of tenants require physical separation, guaranteed data residency, or per-tenant encryption keys, a silo per tenant is usually simpler to audit than proving that application-level propagation is correct across every hop.
- Systems with no tenant-scoped data yet. An early product with one customer, or an internal tool used by one organization, gets little from propagation machinery. Design the data model with a
tenant_idcolumn so you can add enforcement later, but do not build the interceptor libraries before shared tenants exist. - Services that never touch tenant data. Stateless utilities, such as image resizing or format conversion, that receive everything they need in the request do not need to make tenant decisions. Forward the context through them for tracing if convenient, but do not make them verify or enforce it.
- Calls that leave your trust boundary. Do not propagate tenant context to third-party APIs at all. Strip internal tenant headers and baggage at egress, and map to whatever account identifier the external system expects.
Avoid one specific pattern everywhere: half-implemented propagation, where some services enforce tenant context and others silently ignore a missing value. That gives a false sense of isolation. It is often worse than either a clear silo boundary or a fully enforced shared model.
What are the alternatives to propagating tenant context on every call?
Propagation is one way to answer "whose data is this?" Several alternatives answer it differently, and they can be combined.
- Silo per tenant. Run a separate deployment, database, or cloud account for each tenant. Isolation comes from infrastructure, not from application code. The costs are operational: more deployments to upgrade, more databases to migrate, and less efficient resource use for small tenants.
- Cell-based or pooled-by-group deployments. Group tenants into cells, each a full copy of the stack serving a subset of tenants. A routing layer at the edge sends each tenant to its cell. A bug in one cell can only reach the tenants in that cell. You still need tenant context inside a cell, but the potential exposure is smaller.
- Database or schema per tenant with per-tenant credentials. Each tenant's data store is reachable only with its own credential. A service that connects with tenant A's credential cannot read tenant B's data regardless of what context it was handed. The trade-off is connection count and repeated migrations, as shown in the data layer table.
- Derive the tenant from the resource, not the request. Instead of trusting a propagated tenant ID, a service looks up which tenant owns the resource it was asked to act on, such as an order ID, and checks that against the caller's verified identity. This removes the propagated claim as a point of attack. It costs a lookup per request and requires every resource to have a recorded owner.
- Token exchange per hop. Rather than forwarding the user's token or a bare header, each service exchanges its incoming credential for a new token scoped to the next audience and the same tenant. Every hop can verify, and no service holds a token valid everywhere. It needs an authorization server that supports exchange and adds a call per hop unless tokens are cached.
- Explicit tenant parameters in the API. Make the tenant part of the resource path or a required request field, and validate it against the verified identity at each service. This is visible in the API contract and easy to test. It is also more verbose, and it does not remove the need for validation, since the value is still client-supplied until checked.
Many systems end up with a mix: cells for blast-radius control, shared services with propagated and verified context inside each cell, RLS at the data layer, and silos for the few tenants that require them.
How do you observe, test, and budget for tenant context propagation?
Observability
Put the tenant ID on every log line and span attribute. Logs and traces are stored per event, so an extra field adds little cost. Metrics are different. Each distinct label value creates a new time series, so a tenant_id label on a latency histogram multiplies series count by the number of tenants. Add tenant labels only to a few low-cardinality metrics, such as per-tier request rates, and send per-tenant analysis to traces. When you suspect an isolation bug, search traces for spans whose tenant attribute differs from the root span's. That mismatch shows you the hop where context was lost or overwritten.
Testing
You need three layers of tests:
- Contract tests that assert each client interceptor forwards the tenant header and each server interceptor rejects a request that lacks it.
- Concurrent integration tests. For example, start 50 requests as tenant A and 50 as tenant B in parallel, routed through the same thread pool. Have each handler record the tenant it observed and the rows it read. Assert that every result matches the tenant of the request that produced it.
- Targeted async tests for executor handoffs, scheduled jobs, and message consumers. These are where thread-local context is most often dropped.
Budget
Illustrative estimate (assumed numbers, not a benchmark):
Savings = (services × weekly hours each service spends on manual header handling × weeks) + (leaks caught × hours per incident)
Assume 20 services × 0.5 h × 50 weeks = 500 h, plus 3 leaks × 40 h = 120 h, for 620 h saved per year. Compare that with an assumed upfront cost of 300 h for shared middleware, RLS migrations, and the test harness.
How does centralized tenant context propagation save engineering time?
The time savings come from moving tenant handling out of individual handlers and into a few shared, tested places.
- Less code per feature. With one inbound middleware, one outbound interceptor, and scoped repositories or RLS, a new endpoint does not need any tenant-handling code. Engineers write business logic, and the scoping happens automatically.
- Faster, narrower code review. When the data layer fails closed, reviewers do not have to check every query for a missing
tenant_idpredicate. They can concentrate on the shared library and on any code that uses the privileged cross-tenant role. - Cheaper onboarding of new services. A new service imports the shared middleware and consumer wrapper and inherits the same behavior. Without that, each team reinvents header handling and makes its own mistakes.
- Fewer and shorter isolation incidents. Fail-closed checks turn lost context into an immediate error in development or testing instead of a cross-tenant leak found in production. When something does go wrong, tenant attributes on spans and logs let you find the failing hop by searching traces, rather than reading code across many services.
- Quicker customer debugging. Support and on-call engineers can filter logs and traces by tenant to scope a reported problem to one customer's requests, which shortens triage.
- Reusable test harnesses. The concurrent cross-tenant integration test and the interceptor contract tests are written once and run against every service, instead of each team designing its own isolation tests.
The savings depend on how many services you run and how often tenant bugs occur. The illustrative estimate in the budget section shows one way to compare them against the upfront cost using your own assumed inputs. For a system with only a few services, the shared library may not pay for itself until the service count grows.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to multi-tenant microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.