Handling Tenant Context in GraphQL APIs
GraphQL collapses every query into a single endpoint, so the tenant boundary has to live in the execution context rather than in the URL. This page is part of the tenant context injection strategies reference, and it covers how to resolve a tenant identity once per request, freeze it, and carry it through resolvers, DataLoaders, and subscriptions without leaking across workspaces.
Problem Framing
A REST handler maps one route to one operation, so you can attach tenant middleware per path. GraphQL does not work that way. One POST /graphql carries arbitrary nested fields, batched operations, and long-lived subscriptions over the same socket. If the tenant identifier is read inside individual resolvers, every resolver becomes a place where someone forgets the filter, and a single missed WHERE tenant_id = ? returns another customer's rows with a 200 OK.
The fix is to make tenant identity a property of the request, not of any one field. The context value GraphQL builds once per request is the right place: it is created before schema execution, it is visible to every resolver, and it can be made immutable. Everything downstream — query scoping, batching, caching, pub/sub — reads from that single object. Resolve it once, verify it, freeze it, then propagate it.
The flow below shows the lifecycle: extract and verify the token in the context factory, abort early on failure, and only then execute resolvers against a frozen tenant context.
Step-by-Step Guide
1. Extract and verify the tenant in the context factory
The context factory runs once per request. Parse the token, verify its signature, and reject anything without a tenant claim before the schema executes. Returning a frozen object stops any resolver from mutating the tenant identity mid-request.
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import jwt from 'jsonwebtoken';
interface TenantContext {
tenantId: string;
userRole: string;
dataLoaders: ReturnType<typeof createTenantDataLoaders>;
}
const server = new ApolloServer<TenantContext>({ typeDefs, resolvers });
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }): Promise<TenantContext> => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) throw new Error('Missing auth token');
const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, {
algorithms: ['RS256'],
}) as { tenantId: string; role: string };
if (!decoded.tenantId) throw new Error('Missing tenant context in token');
return Object.freeze({
tenantId: decoded.tenantId,
userRole: decoded.role,
dataLoaders: createTenantDataLoaders(decoded.tenantId),
});
},
}));
2. Add a fail-fast plugin guard
A context factory can be bypassed if a future resolver path constructs its own context (for example, internal stitched schemas). An Apollo plugin gives you one chokepoint that aborts every operation lacking a tenant before any field resolves.
import { ApolloServerPlugin } from '@apollo/server';
export const tenantGuardPlugin: ApolloServerPlugin<TenantContext> = {
async requestDidStart() {
return {
async executionDidStart({ contextValue }) {
if (!contextValue.tenantId) {
throw new Error('Tenant context missing — request aborted');
}
},
};
},
};
3. Push scoping into the ORM, not the resolver
Filtering by hand in each resolver guarantees that one of them will eventually omit the tenant predicate. Scope the query at the ORM layer so the boundary holds regardless of resolver code. A Prisma client extension injects the tenant filter into every model read, which is the same approach described in Prisma client extensions for tenant scoping.
import { PrismaClient } from '@prisma/client';
export function tenantScopedClient(tenantId: string) {
return new PrismaClient().$extends({
query: {
$allModels: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
},
});
}
4. Build DataLoaders per request, keyed by tenant
DataLoader batches and caches within a single request. A loader created at module scope outlives requests and will hand one tenant the rows another tenant just loaded. Create loaders inside the context factory (step 1 already wires this up) and prefix cache keys with the tenant ID.
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
const db = new PrismaClient();
export const createTenantDataLoaders = (tenantId: string) => ({
users: new DataLoader(
async (ids: readonly string[]) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] }, tenantId },
});
return ids.map((id) => users.find((u) => u.id === id) ?? null);
},
{ cacheKeyFn: (key: string) => `${tenantId}:${key}` },
),
});
5. Namespace subscription channels per tenant
Subscriptions hold a socket open across many messages, so the tenant must bind at connection setup and the pub/sub topic must include the tenant ID. Without namespacing, every subscriber on a topic receives every tenant's events.
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
export const resolvers = {
Subscription: {
orderUpdated: {
subscribe: (_: unknown, __: unknown, context: TenantContext) => {
if (!context.tenantId) throw new Error('Tenant context required');
const channel = `tenant:${context.tenantId}:orders`;
return pubsub.asyncIterator([channel]);
},
resolve: (payload: { order: unknown }) => payload.order,
},
},
};
The same tenant-on-the-socket discipline applies when work leaves the request entirely; see propagating tenant context across async jobs for the queue-side counterpart.
Verification
Two assertions catch the most common regressions. First, prove that a query carrying tenant A's token cannot read tenant B's record. Second, prove that DataLoader caches do not survive across requests.
import { expect, test } from 'vitest';
test('rejects cross-tenant record access', async () => {
const res = await execute({
query: `query { order(id: "${tenantB.orderId}") { id } }`,
contextValue: Object.freeze({ tenantId: tenantA.id, userRole: 'admin' }),
});
expect(res.data?.order).toBeNull();
});
test('dataloaders are not shared across requests', () => {
const a = createTenantDataLoaders('tenant-a');
const b = createTenantDataLoaders('tenant-b');
expect(a.users).not.toBe(b.users);
});
A passing run looks like this, with no cross-tenant row returned:
$ npx vitest run tenant-context
✓ rejects cross-tenant record access (12 ms)
✓ dataloaders are not shared across requests (1 ms)
Test Files 1 passed (1)
Tests 2 passed (2)
Failure Modes & Gotchas
- Module-scoped DataLoader. Symptom: one tenant intermittently sees another's records under load. Root cause: a singleton loader caches across requests. Fix: instantiate loaders inside the context factory only.
- Mutated context object. Symptom: a resolver overwrites
tenantIdand later fields query the wrong tenant. Root cause: the context object is writable. Fix:Object.freeze()it after construction. - Un-namespaced subscription topics. Symptom: subscribers receive other tenants' events. Root cause: a shared channel name like
orders. Fix: publish and subscribe ontenant:{id}:orders. - Deprecated
SchemaDirectiveVisitor. Symptom: directive guards silently stop running after agraphql-toolsv7 upgrade. Root cause: the class was removed in v7. Fix: implement directives withmapSchemaand aSchemaMapperfrom@graphql-tools/utils.
FAQ
Should tenant filtering live in resolvers or the ORM layer? The ORM layer, or database row-level security. Per-resolver filtering causes N+1 degradation and leaks the moment one resolver forgets the predicate; an ORM extension applies the boundary to every query automatically.
Can a GraphQL subscription keep tenant context over a WebSocket?
Yes. Resolve the tenant during the connection-init phase, attach it to the socket's context, and namespace every pub/sub topic as tenant:{id}:* so subscribers only receive their own events.
How do I stop DataLoader from leaking data across tenants? Never use a global loader. Create loaders per request in the context factory and prefix every cache key with the tenant ID.