Project: Multi-Tenant SaaS
Multi-tenancy lets a single deployment of your application serve many customers (tenants) while keeping their data logically — and sometimes physically — separate. Getting this right is the difference between a SaaS that scales cleanly and one that leaks data across accounts. In this project you’ll resolve the tenant on every request, pick an isolation strategy, wire up request-scoped providers, and make authentication tenant-aware.
Choosing an isolation strategy
There are three classic approaches to tenant data isolation. Each trades operational complexity for stronger separation.
| Strategy | Separation | Cost / complexity | Best for |
|---|---|---|---|
| Row-level (shared schema) | A tenantId column on every row | Lowest — one DB, one schema | Many small tenants, fast onboarding |
| Schema-per-tenant | Separate Postgres schema per tenant | Medium — migrations per schema | Mid-size tenants needing soft isolation |
| Database-per-tenant | Separate database/connection per tenant | Highest — connection pooling, ops | Enterprise tenants, compliance, noisy-neighbor isolation |
Row-level isolation is the most common starting point because it scales to thousands of tenants on shared infrastructure. The risk is that a single missing WHERE tenantId = ? clause leaks data — so you centralize that filtering rather than scattering it across queries.
Never rely on the client to send its own
tenantIdin the request body. Always derive the tenant from a trusted source: a verified JWT claim, a subdomain, or a custom header validated against the authenticated user.
Resolving the tenant per request
The tenant is resolved from the incoming request — typically a subdomain (acme.app.com), a header (X-Tenant-ID), or a JWT claim. A small middleware extracts it and attaches it to the request object before guards and providers run.
// tenant/tenant.middleware.ts
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
export interface TenantRequest extends Request {
tenantId?: string;
}
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: TenantRequest, _res: Response, next: NextFunction) {
const host = req.headers.host ?? '';
const subdomain = host.split('.')[0];
const headerTenant = req.headers['x-tenant-id'] as string | undefined;
const tenantId = headerTenant ?? (subdomain && subdomain !== 'www' ? subdomain : undefined);
if (!tenantId) {
throw new BadRequestException('Unable to resolve tenant');
}
req.tenantId = tenantId;
next();
}
}
Register the middleware globally so it runs for every route.
// app.module.ts
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { TenantMiddleware } from './tenant/tenant.middleware';
import { TenantModule } from './tenant/tenant.module';
@Module({
imports: [TenantModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
A request-scoped tenant context
To make the resolved tenant available anywhere in the DI graph, expose it through a request-scoped provider. NestJS creates a fresh instance per request, so it can safely capture per-request state.
// tenant/tenant-context.service.ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { TenantRequest } from './tenant.middleware';
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
constructor(@Inject(REQUEST) private readonly req: TenantRequest) {}
get tenantId(): string {
if (!this.req.tenantId) {
throw new Error('Tenant not resolved for this request');
}
return this.req.tenantId;
}
}
Request-scoped providers bubble up: any provider that injects
TenantContext(and its consumers) also becomes request-scoped, which adds per-request instantiation overhead. Keep the scoped surface small and resolve the tenant once.
Enforcing row-level isolation
Centralize the tenant filter so application code never forgets it. With Prisma you can wrap queries; with TypeORM you can apply the tenant in the repository layer. Here’s a tenant-aware service built on Prisma.
// projects/projects.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TenantContext } from '../tenant/tenant-context.service';
@Injectable()
export class ProjectsService {
constructor(
private readonly prisma: PrismaService,
private readonly tenant: TenantContext,
) {}
findAll() {
return this.prisma.project.findMany({
where: { tenantId: this.tenant.tenantId },
});
}
create(name: string) {
return this.prisma.project.create({
data: { name, tenantId: this.tenant.tenantId },
});
}
}
Because ProjectsService injects the request-scoped TenantContext, it automatically scopes every query to the current tenant — there is no path for a caller to query another tenant’s rows.
Tenant-aware authentication
Authentication must verify that the authenticated user actually belongs to the resolved tenant. A guard cross-checks the JWT’s tenant claim against the request tenant.
// auth/tenant.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const userTenant = req.user?.tenantId;
if (!userTenant || userTenant !== req.tenantId) {
throw new ForbiddenException('User does not belong to this tenant');
}
return true;
}
}
Apply it after your JWT auth guard so req.user is populated.
// projects/projects.controller.ts
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TenantGuard } from '../auth/tenant.guard';
import { ProjectsService } from './projects.service';
@UseGuards(AuthGuard('jwt'), TenantGuard)
@Controller('projects')
export class ProjectsController {
constructor(private readonly projects: ProjectsService) {}
@Get()
findAll() {
return this.projects.findAll();
}
@Post()
create(@Body('name') name: string) {
return this.projects.create(name);
}
}
A request from tenant acme carrying a token issued for tenant globex is rejected:
Output:
GET /projects Host: acme.app.com Authorization: Bearer <globex-token>
HTTP/1.1 403 Forbidden
{
"statusCode": 403,
"message": "User does not belong to this tenant",
"error": "Forbidden"
}
Database-per-tenant connections
For stronger isolation, resolve a connection per tenant instead of filtering rows. A factory provider can look up the tenant’s database URL and return a dedicated client, cached per tenant.
// tenant/tenant-connection.provider.ts
import { Provider, Scope } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { TenantContext } from './tenant-context.service';
const clients = new Map<string, PrismaClient>();
export const TenantConnectionProvider: Provider = {
provide: 'TENANT_DB',
scope: Scope.REQUEST,
inject: [TenantContext],
useFactory: (tenant: TenantContext) => {
const id = tenant.tenantId;
if (!clients.has(id)) {
clients.set(id, new PrismaClient({ datasources: { db: { url: `postgres://db/${id}` } } }));
}
return clients.get(id)!;
},
};
Caching clients avoids exhausting the connection pool by re-creating a PrismaClient on every request.
Best Practices
- Derive
tenantIdonly from trusted, server-verified sources (JWT claims, validated subdomains) — never from a request body. - Centralize the tenant filter in one layer (a scoped service, repository, or Prisma extension) so no query can forget it.
- Keep request-scoped providers minimal; resolve the tenant once and pass the id rather than scoping your whole graph.
- Always cross-check the authenticated user’s tenant against the resolved request tenant in a guard.
- Add a composite index on
(tenantId, ...)for row-level tenancy so per-tenant queries stay fast. - For database-per-tenant, cache and reuse connections, and bound the pool to avoid noisy-neighbor exhaustion.
- Write integration tests that assert tenant A can never read tenant B’s data across every endpoint.