The challenge of data isolation
Building a multi-tenant CRM means managing data for dozens or hundreds of customers in a single database without allowing one tenant to accidentally (or deliberately) see another's confidential information. Multi-tenancy in NestJS: real-world lessons and examples teaches us that this isn't just a database problem—it's an architectural challenge that spans middleware, context management, query filtering, and security policies.
In production, we've learned that a single forgotten filter or misconfigured query can expose sensitive data. This article walks through the patterns we've refined across multiple SaaS deployments, starting from the moment a request arrives until data is returned safely filtered.
Resolve the tenant early
The first rule: know who the tenant is before you do anything else. We extract the tenant identifier as early as possible—typically in middleware—so it's available throughout the entire request lifecycle.
Tenant identification can come from several sources. In our CRM, we read it from a JWT claim (the most reliable), fall back to subdomain parsing for convenience, and accept a custom header for programmatic clients. Here's how we structure it:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
let tenantId: string | null = null;
// Priority 1: Extract from JWT
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
try {
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET) as any;
tenantId = decoded.tenantId;
} catch (err) {
// Fall through to other methods
}
}
// Priority 2: Extract from subdomain
if (!tenantId) {
const host = req.headers.host || '';
const parts = host.split('.');
if (parts.length > 2) {
tenantId = parts[0]; // acme.crm.local → acme
}
}
// Priority 3: Extract from custom header
if (!tenantId) {
tenantId = req.headers['x-tenant-id'] as string;
}
if (!tenantId) {
return res.status(400).json({ error: 'Tenant not identified' });
}
// Attach to request for later use
(req as any).tenantId = tenantId;
next();
}
}
Register this middleware in your app module to run on every request. The key insight: by the time we reach any controller or service, we've already validated that a tenant context exists.
Store it in request context
Once we've identified the tenant, we need to make that value accessible throughout the request without threading it through every function parameter. AsyncLocalStorage from Node's async_hooks module is perfect for this. It provides request-scoped storage without the overhead of request-scoped providers.
We create a service to manage the tenant context:
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
@Injectable()
export class TenantContextService {
private readonly storage = new AsyncLocalStorage<string>();
setTenantId(tenantId: string) {
this.storage.enterWith(tenantId);
}
getTenantId(): string {
const tenantId = this.storage.getStore();
if (!tenantId) {
throw new Error('Tenant context not set');
}
return tenantId;
}
}
Then, after middleware identifies the tenant, we set it in the context. We do this in a guard that runs early in the request pipeline:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { TenantContextService } from './tenant-context.service';
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private tenantContext: TenantContextService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const tenantId = request.tenantId;
if (!tenantId) {
throw new Error('Tenant ID not found in request');
}
this.tenantContext.setTenantId(tenantId);
return true;
}
}
Apply this guard globally in your app module. Now any service can call this.tenantContext.getTenantId() to retrieve the current tenant, and the value is automatically isolated per request even in async operations.
Filter automatically
With the tenant context available, the next step is ensuring every database query includes the correct filter. We've found that Prisma extensions make this nearly transparent. Instead of manually adding where: { tenantId } to hundreds of queries, we inject it automatically.
Here's a Prisma extension that wraps common operations:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { TenantContextService } from './tenant-context.service';
@Injectable()
export class PrismaService extends PrismaClient {
constructor(private tenantContext: TenantContextService) {
super();
}
async onModuleInit() {
this.$use(async (params, next) => {
// Get the current tenant
const tenantId = this.tenantContext.getTenantId();
// List of models that must be tenant-filtered
const tenantAwareModels = [
'contact',
'company',
'deal',
'note',
'user',
];
if (tenantAwareModels.includes(params.model)) {
// For read operations, add tenantId to the where clause
if (['findUnique', 'findFirst', 'findMany'].includes(params.action)) {
params.args.where = params.args.where || {};
params.args.where.tenantId = tenantId;
}
// For write operations, add tenantId to the data
if (['create', 'update'].includes(params.action)) {
params.args.data.tenantId = tenantId;
}
// For delete, verify the record belongs to this tenant
if (params.action === 'delete') {
params.args.where.tenantId = tenantId;
}
}
return next(params);
});
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
The middleware automatically injects tenantId into every query for tenant-aware models. This approach dramatically reduces the chance of accidental data leakage—you'd have to explicitly bypass Prisma to miss the filter.
Defense in depth
Application-level filtering is powerful, but a second layer of protection at the database level catches mistakes that slip through the application code. PostgreSQL row-level security (RLS) policies enforce tenant isolation even if a query somehow bypasses your middleware or extension.
For a contacts table, an RLS policy might look like this:
// This is SQL, run in a migration
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON contacts
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation_policy_write ON contacts
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
Before executing queries, set the tenant context in the database session:
// In your Prisma middleware, after connecting
await this.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, false)`;
Now, even if a developer accidentally writes a query without a tenantId filter, the database won't return rows that don't match the current tenant. This is a safety net, not a replacement for application filtering—but it's invaluable in production.
Common mistakes
- Trusting the subdomain alone: Subdomains can be spoofed in requests. Always prefer JWT claims, and only use subdomains as a convenience fallback after JWT validation fails.
- Forgetting to filter in specific queries: The Prisma extension helps, but raw queries or operations on pivot tables might slip through. Document which models are tenant-aware and audit queries during code review.
- Setting tenant context after spawning async tasks: If you use
setImmediate,setTimeout, or fire-and-forget operations, the async context may not carry through. Always ensure context is set before starting async work. - Disabling RLS for performance: We've seen teams disable row-level security to squeeze out a few milliseconds. The performance impact is minimal compared to the risk; keep it enabled in production.
- Not testing tenant isolation: Write explicit tests that verify one tenant cannot read another's data, even with crafted queries or IDs. This catches integration bugs before production.
Key takeaways
- Resolve tenant identity early—in middleware—from JWT, subdomain, or header, in that priority order.
- Store the tenant ID in AsyncLocalStorage via a context service so it's available throughout the request without function threading.
- Use Prisma extensions to inject tenantId filters automatically into all tenant-aware queries, reducing manual errors.
- Implement PostgreSQL row-level security as a defense-in-depth layer that catches application-level mistakes.
- Test tenant isolation explicitly; a single gap can expose sensitive data to the wrong customer.






Comments
Loading comments…