Handling Tenant Context in GraphQL APIs

Multi-tenant GraphQL architectures require strict boundary enforcement at the execution layer. Tenant context propagation must occur before resolver evaluation. Secure data isolation relies on immutable payloads and request-scoped lifecycles.

Cross-tenant leakage remains a critical failure mode in SaaS platforms. This guide details implementation patterns for context injection, directive enforcement, and ORM-level scoping. We prioritize fail-fast validation and automated query transformation. Scaling limits and memory boundaries are explicitly mapped throughout.

GraphQL Context Object & Tenant Lifecycle

The GraphQL context object initializes once per HTTP or WebSocket request. It acts as the authoritative source for tenant identity. Extraction must occur before schema execution begins.

JWT or secure cookie parsing happens in the context factory. The decoded payload attaches an immutable tenant identifier. This identifier never mutates during the request lifecycle. Request-scoped management prevents worker pool contamination.

Request Header Extraction Method Context Field Validation Rule
Authorization: Bearer <token> JWT decode tenantId Must match active subscription tier
Cookie: sid=<session> Secure session store tenantId Must be cryptographically signed
X-Tenant-ID (Internal) Header passthrough tenantId Must match service-to-service allowlist
Connection-Init (WS) WebSocket payload tenantId Must validate before subscription setup

Context immutability prevents downstream race conditions. Use Object.freeze() immediately after factory resolution. This guarantees tenant boundaries remain static during resolver traversal.

Middleware & Directive-Based Injection

Pre-execution hooks standardize tenant validation across the schema. Apollo Server and GraphQL Yoga expose plugin architectures for this purpose. Middleware intercepts operations before field resolution begins.

Custom directives enforce tenant awareness at the schema level. The @tenantScoped directive wraps field resolvers. It validates context presence before execution proceeds. Fail-fast validation pipelines reject malformed requests immediately.

Integrating Tenant Context Injection Strategies ensures payload validation aligns with platform-wide standards. Middleware intercepts batched queries and mutations uniformly. Directive enforcement scales without resolver duplication.

Resolver-Level Query Scoping & ORM Integration

Manual tenant filtering in resolvers introduces security drift. ORM middleware hooks automate WHERE tenant_id = ? injection. This aligns execution with Tenant-Aware Data Routing & Query Scoping for consistent isolation.

Database-level routing eliminates developer oversight. Prisma and Sequelize support global query extensions. Middleware intercepts all read/write operations before SQL generation. Tenant boundaries enforce strict row-level isolation.

Approach Implementation Security Posture Performance Impact
Manual Resolver Filter args.filter.tenantId = ctx.tenantId High leak risk N+1 degradation
ORM Middleware Hook prisma.$use({ query: injectTenant }) Strict enforcement Negligible overhead
Database RLS ALTER TABLE ... ENABLE ROW LEVEL SECURITY Hardware-enforced Query planner optimization

Automatic scoping prevents accidental cross-tenant joins. Resolver code remains clean and domain-focused. Query planners optimize tenant-prefixed indexes efficiently. Scaling limits depend on connection pool sizing and index coverage.

Subscription & Real-Time Tenant Isolation

WebSockets maintain persistent connections across multiple operations. Context must bind during the onConnect phase. Long-lived sessions require explicit tenant attachment. Memory management prevents abandoned channel accumulation.

PubSub topics require strict namespacing. Format channels as tenant:{id}:events. This isolates broadcast traffic per tenant. Subscribers only receive scoped payloads.

Connection-level auth binding prevents context drift. Implement explicit onDisconnect handlers. Release memory references and unsubscribe from PubSub channels. Graceful disconnects prevent memory leaks under high concurrency.

Caching & DataLoader Tenant Boundaries

Caching layers introduce cross-tenant poisoning risks. DataLoader instances must instantiate per-request. Global singletons violate tenant isolation guarantees. Request-scoped instantiation guarantees clean batch boundaries.

Cache keys require tenant prefixes. Format keys as {tenantId}:{entityType}:{entityId}. This prevents key collisions across isolated workspaces. Invalidation triggers on context changes or explicit mutations.

Cache Layer Key Format Isolation Mechanism Invalidation Trigger
DataLoader tenant:123:user:abc Per-request instance Request completion
Redis/Memcached saas:tenant:123:query:hash Namespace prefix Explicit DEL or TTL
In-Memory LRU tenant:123:resolver:field Context-bound map Context destruction

Request-scoped DataLoaders prevent batch leakage across HTTP requests. Cache invalidation aligns with tenant context lifecycle. Scaling limits depend on cache eviction policies and memory allocation per worker.

Implementation Snippets

Context Factory with Tenant Extraction

import { ContextFunction } from 'apollo-server-core';
import jwt from 'jsonwebtoken';

const contextFactory: ContextFunction = async ({ req }) => {
 const token = req.headers.authorization?.split(' ')[1];
 if (!token) throw new Error('Missing auth token');
 
 const decoded = jwt.verify(token, process.env.JWT_SECRET) as { tenantId: string, role: string };
 
 return {
 tenantId: decoded.tenantId,
 userRole: decoded.role,
 dataLoaders: createTenantDataLoaders(decoded.tenantId)
 };
};

Custom Directive for Tenant Enforcement

import { SchemaDirectiveVisitor, defaultFieldResolver } from 'graphql-tools';

class TenantScopedDirective extends SchemaDirectiveVisitor {
 visitFieldDefinition(field) {
 const { resolve = defaultFieldResolver } = field;
 field.resolve = async function (...args) {
 const context = args[2];
 if (!context.tenantId) throw new Error('Tenant context missing');
 return resolve.apply(this, args);
 };
 }
}

Tenant-Scoped DataLoader with Cache Key Isolation

import DataLoader from 'dataloader';

export const createTenantDataLoaders = (tenantId: string) => ({
 users: new DataLoader(async (ids: string[]) => {
 const users = await db.users.findMany({
 where: { id: { in: ids }, tenantId: tenantId }
 });
 return ids.map(id => users.find(u => u.id === id) || null);
 }, { cacheKeyFn: key => `${tenantId}:${key}` })
});

Pitfalls & Anti-Patterns

Pattern Failure Mode Remediation
Global DataLoader Singleton Cross-tenant cache poisoning; User A sees User B's data across requests. Instantiate DataLoaders per-request inside the context factory. Never share batch loaders across HTTP requests.
Resolver-Level Manual Filtering N+1 query explosion; inconsistent security enforcement; developer oversight leaks data. Push tenant filtering to ORM middleware or database-level RLS. Use schema directives for fail-fast validation.
WebSocket Context Mutation Stale tenant context during long-lived subscriptions; memory leaks from abandoned channels. Bind tenant ID to WebSocket connection init phase. Implement heartbeat checks and explicit onDisconnect cleanup.
Context Object Over-Mutation Race conditions in pooled Node.js workers; unexpected tenant ID overrides. Freeze context object after initialization. Use Object.freeze() or immutable data structures for tenant payload.

Frequently Asked Questions

How do I prevent DataLoader from leaking tenant data across requests? Never use a global DataLoader instance. Instantiate it per-request in the GraphQL context factory, and scope cache keys with {tenantId}:{entityId}.

Can GraphQL subscriptions maintain tenant context over WebSockets? Yes. Extract tenant ID during the onConnect phase, attach it to the WebSocket context, and namespace PubSub channels with tenant:{id}:*.

Should tenant filtering happen in resolvers or at the ORM layer? ORM layer or database-level Row-Level Security (RLS). Resolver-level filtering causes N+1 degradation and inconsistent security boundaries.