Multi-Tenant SaaS Security: Best Practices Guide
A practical guide to building secure multi-tenant architecture based on a real SaaS application
๐ Table of Contents
- Introduction
- Architectural Principles
- Layer 1: Reverse Proxy (Nginx)
- Layer 2: Application Security
- Layer 3: Data Isolation
- Layer 4: Access Control (RBAC)
- Common Vulnerabilities
- Production Checklist
Introduction
What is Multi-Tenancy?
Multi-tenancy is an architectural pattern where a single application serves multiple customers (tenants) while ensuring complete isolation of their data.
Three Types of Isolation
| Type | Description | Security | Cost |
|---|---|---|---|
| Database-per-tenant | Separate DB for each tenant | โญโญโญโญโญ | ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ |
| Schema-per-tenant | Separate schema in one DB | โญโญโญโญ | ๐ฐ๐ฐ๐ฐ |
| Row-level isolation | One DB, filter by tenantId | โญโญโญ | ๐ฐ |
In this guide: Row-level isolation with subdomain routing (optimal balance)
Architectural Principles
Defense in Depth
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Layer 1: Network (Nginx) โ โ - Subdomain extraction โ โ - Rate limiting โ โ - Security headers โ โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Layer 2: Application (Guards) โ โ - JWT authentication โ โ - Tenant validation โ โ - Team membership check โ โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Layer 3: Data (Database) โ โ - Query filtering by tenantId โ โ - Compound indexes โ โ - Redis cache isolation โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Zero Trust Principle
โ Never trust:
- Client-side validations
- Data from URL
- Headers without validation
โ Always verify:
- At each layer independently
- User's membership in tenant
- Access rights to resources
Layer 1: Reverse Proxy (Nginx)
1.1 Subdomain-based Routing
Goal: Extract tenant identifier from URL and pass it to the application.
โ Bad:
server { server_name *.localhost; # Too broad! location /api { proxy_pass http://backend; } }
Problems:
- No subdomain extraction
- Can't distinguish between tenants
- Reserved subdomains not protected
โ Good:
# Tenant dashboards: *.localhost or *.c10so.local (wildcard) # IMPORTANT: This block MUST come FIRST! server { listen 80; server_name ~^(?<subdomain>[^.]+)\.(localhost|c10so\.local)$; # Skip reserved subdomains if ($subdomain = "app") { return 444; # Close connection } # API endpoints with tenant header injection location /api { # Rate limiting per tenant limit_req zone=api_limit burst=20 nodelay; # โญ CRITICAL: Extract subdomain and pass as header proxy_set_header X-Tenant-Slug $subdomain; proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # Frontend (tenant dashboard) location / { proxy_set_header X-Tenant-Slug $subdomain; proxy_pass http://frontend-dashboard; } } # Auth app: app.localhost (reserved subdomain) server { listen 80; server_name app.localhost app.c10so.local; location / { proxy_pass http://frontend-auth; } } # Marketing: localhost (no subdomain) server { listen 80; server_name localhost citenso.local; location / { proxy_pass http://frontend-marketing; } }
Key Points:
-
Server block order is critical:
- Regex patterns (
) are processed in order of appearance~^ - Tenant block MUST be FIRST
- Otherwise
might matches-1.localhostlocalhost
- Regex patterns (
-
Named captures:
server_name ~^(?<subdomain>[^.]+)\.(localhost|c10so\.local)$; # โโโโโโโโโโโโโโโโโโโโโโโ # Variable $subdomain now available -
Reserved subdomains:
if ($subdomain = "app") { return 444; } if ($subdomain = "www") { return 444; } if ($subdomain = "api") { return 444; }
1.2 Security Headers
# Security headers for all responses add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
What they do:
| Header | Protects Against | Value |
|---|---|---|
| X-Frame-Options | Clickjacking | SAMEORIGIN = only own frames |
| X-Content-Type-Options | MIME sniffing | nosniff = don't guess content-type |
| X-XSS-Protection | XSS (deprecated) | mode=block = block suspicious content |
| Referrer-Policy | URL leakage | strict-origin = don't pass full URL |
1.3 Rate Limiting
http { # Rate limiting zones limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/m; limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m; server { # API endpoints location /api { limit_req zone=api_limit burst=20 nodelay; # ... } # Auth endpoints (stricter) location ~ ^/api/auth/callback { limit_req zone=login_limit burst=2 nodelay; # ... } } }
Best Practices:
- 30 requests/minute for API โ balance between UX and protection
- 5 requests/minute for auth endpoints โ brute-force protection
- burst โ allowed request spike
- nodelay โ don't delay requests within burst
1.4 CORS Headers
โ Dangerous for Production:
add_header 'Access-Control-Allow-Origin' '*' always;
โ Secure:
# Development only - use whitelist in production set $cors_origin ""; # Check origin if ($http_origin ~* (^https?://(.*\.)?c10so\.com$)) { set $cors_origin $http_origin; } add_header 'Access-Control-Allow-Origin' $cors_origin always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Tenant-Slug' always; add_header 'Access-Control-Allow-Credentials' 'true' always;
Layer 2: Application Security
2.1 Backend: Helmet (NestJS/Express)
Installation:
npm install helmet
Configuration (main.ts):
import { NestFactory } from '@nestjs/core'; import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule); // โญ Helmet for security headers app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], // For styled-components imgSrc: ["'self'", "data:", "https:"], connectSrc: [ "'self'", "https://api.openai.com", // Allowed APIs "https://api.anthropic.com", "http://localhost:*", // Dev only! ], fontSrc: ["'self'", "data:"], objectSrc: ["'none'"], frameSrc: ["'none'"], }, }, crossOriginEmbedderPolicy: false, // For external API calls crossOriginResourcePolicy: { policy: "cross-origin" }, hidePoweredBy: true, // Hide X-Powered-By: Express referrerPolicy: { policy: 'strict-origin-when-cross-origin', }, })); // CORS app.enableCors({ origin: [ 'http://localhost:3002', // auth frontend 'http://localhost:3003', // dashboard frontend /^http:\/\/.*\.localhost$/, // tenant subdomains ], credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Slug'], }); await app.listen(4000); }
Content Security Policy (CSP):
contentSecurityPolicy: { directives: { // โญ defaultSrc: where to load resources from by default defaultSrc: ["'self'"], // โ ๏ธ scriptSrc: only own scripts (XSS protection) scriptSrc: ["'self'"], // DON'T use 'unsafe-inline' or 'unsafe-eval'! // ๐จ styleSrc: styles (inline needed for styled-components) styleSrc: ["'self'", "'unsafe-inline'"], // ๐ผ๏ธ imgSrc: images imgSrc: ["'self'", "data:", "https:"], // ๐ connectSrc: fetch/XHR requests connectSrc: [ "'self'", "https://api.openai.com", // Whitelist for external APIs ], }, }
2.2 Frontend: Next.js Headers
โ DON'T use helmet in Next.js!
โ Use next.config.js:
// next.config.js module.exports = { poweredByHeader: false, // Hide X-Powered-By: Next.js async headers() { return [ { source: '/:path*', headers: [ { key: 'X-Frame-Options', value: 'SAMEORIGIN', }, { key: 'X-Content-Type-Options', value: 'nosniff', }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin', }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()', }, ], }, ]; }, };
2.3 TenantGuard: Critical Component
Goal: Verify that user has access to the tenant.
// backend/src/common/guards/tenant.guard.ts import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; import { TenantsService } from '../../tenants/tenants.service'; import { TeamService } from '../../team/team.service'; @Injectable() export class TenantGuard implements CanActivate { constructor( private tenantsService: TenantsService, private teamService: TeamService, ) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); // โญ Step 1: Get tenant slug from header (set by nginx) const tenantSlug = request.headers['x-tenant-slug']; if (!tenantSlug) { throw new UnauthorizedException('Tenant not identified'); } // โญ Step 2: Find tenant in DB (with caching) const tenant = await this.tenantsService.findBySlug(tenantSlug); if (!tenant) { throw new ForbiddenException('Tenant not found'); } // โญ Step 3: Check tenant is active if (!tenant.isActive) { throw new ForbiddenException( `Tenant is suspended${tenant.suspensionReason ? `: ${tenant.suspensionReason}` : ''}`, ); } // โญ Step 4: Check user membership if (request.user?.userId) { const isMember = await this.teamService.isMember( tenant._id.toString(), request.user.userId, ); if (!isMember) { throw new ForbiddenException('You are not a member of this tenant'); } // โญ Step 5: Update last activity await this.teamService.updateActivity( tenant._id.toString(), request.user.userId, ); } // โญ Step 6: Attach tenant to request for controllers request.tenant = tenant; request.tenantId = tenant._id.toString(); return true; } }
Usage in controllers:
// backend/src/team/team.controller.ts import { Controller, Get, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { TenantGuard } from '../common/guards/tenant.guard'; @Controller('team') export class TeamController { // โญ Double protection: JWT + Tenant @Get('members') @UseGuards(JwtAuthGuard, TenantGuard) async getMembers(@Request() req) { // request.tenant and request.tenantId available const tenantId = req.tenantId; return this.teamService.getTenantMembers(tenantId); } }
โ ๏ธ Important:
-
Guard order matters:
@UseGuards(JwtAuthGuard, TenantGuard) // โ first โ then -
TenantGuard requires authentication:
- Always use with JwtAuthGuard
- Otherwise
will be undefinedrequest.user
-
Don't trust client-side data:
- Tenant slug ONLY from nginx header
- NOT from query params, cookies or client headers
Layer 3: Data Isolation
3.1 Database Schema Design
MongoDB Schema with tenantId:
// backend/src/prompts/schemas/prompt.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, Types } from 'mongoose'; @Schema({ timestamps: true }) export class Prompt { // โญ REQUIRED: tenantId in every collection @Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true }) tenantId: Types.ObjectId; @Prop({ required: true }) name: string; @Prop({ required: true }) template: string; // ... other fields } export const PromptSchema = SchemaFactory.createForClass(Prompt); // โญ Compound index for fast tenant-scoped queries PromptSchema.index({ tenantId: 1, createdAt: -1 }); PromptSchema.index({ tenantId: 1, name: 1 }, { unique: true });
3.2 Service Layer: Automatic Filtering
โ Dangerous (easy to forget filter):
async findAll() { // ๐จ VULNERABILITY: returns data from ALL tenants! return this.promptModel.find().exec(); }
โ Secure:
// backend/src/prompts/prompts.service.ts import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; import { Prompt, PromptDocument } from './schemas/prompt.schema'; @Injectable() export class PromptsService { constructor( @InjectModel(Prompt.name) private promptModel: Model<PromptDocument>, ) {} // โญ ALWAYS pass tenantId explicitly async findAll(tenantId: string): Promise<Prompt[]> { return this.promptModel .find({ tenantId: new Types.ObjectId(tenantId) }) .sort({ createdAt: -1 }) .exec(); } async findById(tenantId: string, promptId: string): Promise<Prompt> { // โญ Check tenantId BEFORE returning data return this.promptModel .findOne({ _id: new Types.ObjectId(promptId), tenantId: new Types.ObjectId(tenantId), // Critical! }) .exec(); } async create(tenantId: string, createDto: CreatePromptDto): Promise<Prompt> { const prompt = new this.promptModel({ ...createDto, tenantId: new Types.ObjectId(tenantId), // Automatically add }); return prompt.save(); } async update( tenantId: string, promptId: string, updateDto: UpdatePromptDto, ): Promise<Prompt> { // โญ findOneAndUpdate with tenantId check return this.promptModel .findOneAndUpdate( { _id: new Types.ObjectId(promptId), tenantId: new Types.ObjectId(tenantId), // Protection from tampering }, { $set: updateDto }, { new: true }, ) .exec(); } async delete(tenantId: string, promptId: string): Promise<void> { await this.promptModel .deleteOne({ _id: new Types.ObjectId(promptId), tenantId: new Types.ObjectId(tenantId), // Can only delete own }) .exec(); } }
Key Rules:
-
Always first parameter
:tenantIdasync findAll(tenantId: string) // โ required parameter -
Never trust ID from request body:
// โ DANGEROUS async create(createDto: CreatePromptDto) { const prompt = new this.promptModel(createDto); // Client can forge tenantId in DTO! } // โ SECURE async create(tenantId: string, createDto: CreatePromptDto) { const prompt = new this.promptModel({ ...createDto, tenantId: new Types.ObjectId(tenantId), // From guard }); } -
All queries MUST filter by tenantId:
.find({ tenantId: new Types.ObjectId(tenantId) }) .findOne({ _id, tenantId }) .updateOne({ _id, tenantId }, update) .deleteOne({ _id, tenantId })
3.3 Controller Layer: Extract tenantId
// backend/src/prompts/prompts.controller.ts import { Controller, Get, Post, Body, Param, Request, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { TenantGuard } from '../common/guards/tenant.guard'; import { PromptsService } from './prompts.service'; @Controller('prompts') @UseGuards(JwtAuthGuard, TenantGuard) // โญ Guard at controller level export class PromptsController { constructor(private promptsService: PromptsService) {} @Get() async findAll(@Request() req) { // โญ tenantId available thanks to TenantGuard return this.promptsService.findAll(req.tenantId); } @Get(':id') async findOne(@Request() req, @Param('id') id: string) { return this.promptsService.findById(req.tenantId, id); } @Post() async create(@Request() req, @Body() createDto: CreatePromptDto) { return this.promptsService.create(req.tenantId, createDto); } }
3.4 Redis Cache Isolation
Problem: Shared Redis instance for all tenants.
โ Dangerous (cache collision):
await redis.get(`prompt:${promptId}`); // Tenant A can get Tenant B's data!
โ Secure:
// backend/src/tenants/tenants.service.ts private async cacheTenant(tenant: TenantDocument) { const tenantObj = tenant.toObject(); const ttl = 3600; // 1 hour // โญ Prefix with tenant slug await this.redis.setex( `tenant:slug:${tenantObj.slug}`, ttl, JSON.stringify(tenantObj), ); // โญ Prefix with tenant ID await this.redis.setex( `tenant:id:${tenantObj._id}`, ttl, JSON.stringify(tenantObj), ); } async findBySlug(slug: string): Promise<TenantDocument | null> { // โญ Cache key includes slug const cacheKey = `tenant:slug:${slug}`; const cached = await this.redis.get(cacheKey); if (cached) { return JSON.parse(cached); } const tenant = await this.tenantModel.findOne({ slug }).exec(); if (tenant) { await this.cacheTenant(tenant); } return tenant; }
Naming Convention for cache keys:
tenant:slug:{slug} โ Tenant metadata by slug tenant:id:{tenantId} โ Tenant metadata by ID prompt:{tenantId}:list โ List of prompts for tenant prompt:{tenantId}:{promptId} โ Specific prompt user:{userId}:tenants โ List of user's tenants
Layer 4: Access Control (RBAC)
4.1 Team Membership Schema
// backend/src/team/schemas/team-member.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, Types } from 'mongoose'; export enum MemberRole { OWNER = 'owner', // Full control ADMIN = 'admin', // Management + settings MEMBER = 'member', // Use features VIEWER = 'viewer', // Read-only } export enum MemberStatus { ACTIVE = 'active', PENDING = 'pending', // Waiting for invite SUSPENDED = 'suspended', } @Schema({ timestamps: true }) export class TeamMember { @Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true }) tenantId: Types.ObjectId; @Prop({ type: Types.ObjectId, ref: 'User', required: true, index: true }) userId: Types.ObjectId; @Prop({ required: true, lowercase: true }) email: string; @Prop({ type: String, enum: MemberRole, required: true }) role: MemberRole; @Prop({ type: String, enum: MemberStatus, default: MemberStatus.ACTIVE }) status: MemberStatus; // Custom permissions (override role defaults) @Prop({ type: Map, of: Boolean, default: {} }) customPermissions: Map<string, boolean>; @Prop({ type: Date }) lastActiveAt: Date; } export const TeamMemberSchema = SchemaFactory.createForClass(TeamMember); // โญ Compound indexes for fast checks TeamMemberSchema.index({ tenantId: 1, userId: 1 }, { unique: true }); TeamMemberSchema.index({ tenantId: 1, email: 1 }); TeamMemberSchema.index({ userId: 1, status: 1 });
4.2 Permission Checks
// backend/src/team/schemas/team-member.schema.ts (methods) export class TeamMemberDocument extends Document { // โญ Get all permissions for role getDefaultPermissions(): string[] { const permissions = { [MemberRole.OWNER]: [ 'tenant:delete', 'team:manage', 'settings:manage', 'prompts:manage', 'analytics:view', ], [MemberRole.ADMIN]: [ 'team:invite', 'team:remove', 'settings:manage', 'prompts:manage', 'analytics:view', ], [MemberRole.MEMBER]: [ 'prompts:create', 'prompts:view', 'analytics:view', ], [MemberRole.VIEWER]: [ 'prompts:view', 'analytics:view', ], }; return permissions[this.role] || []; } // โญ Check specific permission hasPermission(permission: string): boolean { // Check custom permissions first if (this.customPermissions && this.customPermissions.has(permission)) { return this.customPermissions.get(permission); } // Fallback to default role permissions return this.getDefaultPermissions().includes(permission); } // โญ Can manage another user canManageUser(targetRole: MemberRole): boolean { const roleHierarchy = { [MemberRole.OWNER]: 4, [MemberRole.ADMIN]: 3, [MemberRole.MEMBER]: 2, [MemberRole.VIEWER]: 1, }; return roleHierarchy[this.role] > roleHierarchy[targetRole]; } }
4.3 Permission Guard
// backend/src/common/guards/permission.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { TeamService } from '../../team/team.service'; // Decorator to specify required permission export const RequirePermission = (permission: string) => Reflector.createDecorator<string>(); @Injectable() export class PermissionGuard implements CanActivate { constructor( private reflector: Reflector, private teamService: TeamService, ) {} async canActivate(context: ExecutionContext): Promise<boolean> { // Get required permission from decorator const requiredPermission = this.reflector.get( RequirePermission, context.getHandler(), ); if (!requiredPermission) { return true; // No requirements = access granted } const request = context.switchToHttp().getRequest(); const { tenantId, user } = request; if (!tenantId || !user) { throw new ForbiddenException('Tenant or user not identified'); } // โญ Check permission through TeamService const hasPermission = await this.teamService.hasPermission( tenantId, user.userId, requiredPermission, ); if (!hasPermission) { throw new ForbiddenException( `You don't have permission: ${requiredPermission}`, ); } return true; } }
Usage:
// backend/src/team/team.controller.ts import { RequirePermission } from '../common/guards/permission.guard'; @Controller('team') @UseGuards(JwtAuthGuard, TenantGuard, PermissionGuard) export class TeamController { @Post('invite') @RequirePermission('team:invite') // โญ Permission required async inviteUser(@Request() req, @Body() dto: InviteUserDto) { return this.teamService.inviteUser( req.tenantId, req.user.userId, dto.email, dto.role, ); } @Delete('members/:userId') @RequirePermission('team:remove') // โญ Permission required async removeMember(@Request() req, @Param('userId') userId: string) { return this.teamService.removeMember( req.tenantId, userId, req.user.userId, ); } }
Common Vulnerabilities
1. Tenant Isolation Bypass
Attack Scenario:
1. User A (tenant-a) gets promptId through API 2. User A tries to get prompt via GET /api/prompts/{promptId} 3. If no tenantId check โ User A gets Tenant B's data
โ Vulnerable code:
async findById(promptId: string) { return this.promptModel.findById(promptId).exec(); // ๐จ No tenantId check! }
โ Protected code:
async findById(tenantId: string, promptId: string) { return this.promptModel .findOne({ _id: new Types.ObjectId(promptId), tenantId: new Types.ObjectId(tenantId), // โญ Required check }) .exec(); }
2. Subdomain Takeover
Attack Scenario:
1. Tenant "acme" deletes their account 2. Slug "acme" becomes available 3. Attacker registers tenant "acme" 4. Gets access to old bookmarks: acme.c10so.com
โ Protection:
// backend/src/tenants/tenants.service.ts async create(createDto: CreateTenantDto, ownerId: string) { let slug = this.generateSlug(createDto.companyName); // โญ Check slug uniqueness let counter = 1; while (await this.slugExists(slug)) { slug = `${this.generateSlug(createDto.companyName)}-${counter}`; counter++; } // โญ Reserved slugs const reservedSlugs = ['app', 'www', 'api', 'admin', 'mail', 'ftp']; if (reservedSlugs.includes(slug)) { throw new BadRequestException('This subdomain is reserved'); } // Create tenant... } // โญ Soft delete instead of deletion async delete(tenantId: string) { await this.tenantModel.updateOne( { _id: tenantId }, { $set: { isActive: false, deletedAt: new Date(), // Slug NOT freed! }, }, ); }
3. Header Injection
Attack Scenario:
1. Attacker sends request with header: X-Tenant-Slug: evil-tenant 2. If backend trusts client headers โ bypass!
โ Vulnerable code:
// TenantGuard const tenantSlug = request.headers['x-tenant-slug'] || // ๐จ From client! request.query.tenant; // ๐จ From URL!
โ Protected code:
// TenantGuard const tenantSlug = request.headers['x-tenant-slug']; // โ Set ONLY by nginx, client can't forge // In nginx: proxy_set_header X-Tenant-Slug $subdomain; # $subdomain extracted from URL by nginx, not from request
Important:
- Nginx removes all
headers from client requestX-Tenant-Slug - Sets its own, extracted from subdomain
- Backend TRUSTS only nginx
4. Cache Poisoning
Attack Scenario:
1. Tenant A makes request โ data cached without tenant scope 2. Tenant B makes same request โ gets Tenant A's data
โ Vulnerable code:
const cacheKey = `prompts:list`; const cached = await redis.get(cacheKey); // ๐จ All tenants use same key!
โ Protected code:
const cacheKey = `prompts:${tenantId}:list`; const cached = await redis.get(cacheKey); // โญ Each tenant has own key
5. Mass Assignment
Attack Scenario:
POST /api/prompts { "name": "Test", "template": "...", "tenantId": "evil-tenant-id" // ๐จ Forged tenantId! }
โ Protection:
// DTO without tenantId export class CreatePromptDto { @IsString() @IsNotEmpty() name: string; @IsString() template: string; // โ ๏ธ NO tenantId in DTO! } // Service adds tenantId from guard async create(tenantId: string, dto: CreatePromptDto) { const prompt = new this.promptModel({ ...dto, tenantId: new Types.ObjectId(tenantId), // โญ From guard, not DTO }); return prompt.save(); } // Validation Pipe blocks extra fields app.useGlobalPipes( new ValidationPipe({ whitelist: true, // โญ Remove unknown fields forbidNonWhitelisted: true, // โญ Error on extra fields }), );
Production Checklist
๐ Secrets & Environment
- JWT_SECRET โ minimum 64 characters, generated with
openssl rand -base64 64 - NEXTAUTH_SECRET โ minimum 64 characters
- All secrets in environment variables, not in docker-compose.yml
- Redis password set and not empty
- MongoDB credentials rotated after repository leak
- Google OAuth credentials NOT hardcoded
- .env files in .gitignore
# Generate secrets openssl rand -base64 64 > JWT_SECRET.txt openssl rand -base64 64 > NEXTAUTH_SECRET.txt openssl rand -base64 32 > REDIS_PASSWORD.txt # .env (DON'T commit!) JWT_SECRET=$(cat JWT_SECRET.txt) NEXTAUTH_SECRET=$(cat NEXTAUTH_SECRET.txt) REDIS_PASSWORD=$(cat REDIS_PASSWORD.txt) MONGODB_URI=mongodb+srv://user:${MONGO_PASSWORD}@...
๐ก๏ธ Security Headers
- Helmet installed in backend (NestJS)
- CSP configured with whitelist for external APIs
- X-Frame-Options: SAMEORIGIN in all responses
- X-Content-Type-Options: nosniff in all responses
- Referrer-Policy configured
- X-Powered-By hidden (helmet + Next.js poweredByHeader: false)
- HSTS enabled for HTTPS (production only)
# Production: HTTPS only add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
๐ CORS & Network
- CORS origins โ whitelist specific domains (not
)* - Rate limiting applied to all API endpoints
- Stricter rate limit on auth endpoints (5 req/min)
- Nginx subdomain extraction works correctly
- Reserved subdomains protected (app, www, api)
- HTTPS configured with Let's Encrypt (production)
๐ฅ Multi-Tenancy
- TenantGuard applied to all protected endpoints
- X-Tenant-Slug header set by nginx
- All DB queries filtered by tenantId
- Redis cache keys include tenantId
- Compound indexes created:
{ tenantId: 1, ... } - Soft delete for tenants (slug not freed)
๐ญ RBAC & Permissions
- RBAC roles defined (owner/admin/member/viewer)
- Permission checks in all sensitive endpoints
- TeamMember relationship created on registration
- Invitation flow works correctly
- Role hierarchy enforced when managing users
๐ Monitoring & Audit
- Audit logging for critical operations
- Last activity tracking for users
- Failed login attempts logged
- Tenant suspension mechanism works
- Usage limits enforced for subscription tiers
๐งช Security Testing
- Tenant isolation tested โ user A can't see user B's data
- Permission bypass tested โ member can't delete users
- SQL injection impossible (MongoDB + Mongoose ODM)
- XSS protection works (CSP blocks inline scripts)
- CSRF protection (if using cookies)
- Rate limiting triggers on excess
๐ Code Review Checklist
// โ All methods accept tenantId as first parameter async findAll(tenantId: string) // โ All queries are filtered .find({ tenantId: new Types.ObjectId(tenantId) }) // โ Guards in correct order @UseGuards(JwtAuthGuard, TenantGuard, PermissionGuard) // โ DTO doesn't contain tenantId export class CreatePromptDto { // NO tenantId here! } // โ ValidationPipe configured app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, }));
Best Practices Summary
โญ Golden Rules
-
Trust but Verify
- Verify at each layer
- Don't trust client-side data
- Nginx headers > query params > client headers
-
Defense in Depth
- Nginx (network) + Guards (app) + DB (data)
- One layer is not enough
- Each layer is independent
-
Explicit is Better
always explicit parametertenantId- No implicit context or global state
- Compile-time safety
-
Fail Secure
- Deny by default
- Explicit whitelist > blacklist
- Throw exception if no tenantId
-
Audit Everything
- Log all tenant switches
- Track last activity
- Monitor suspicious patterns
๐ Additional Resources
- OWASP Cloud Tenant Isolation - Guide for mitigating cross-tenant vulnerabilities in cloud applications
- OWASP Authorization Cheat Sheet - Best practices for authorization in multi-tenant environments
- NestJS Security Best Practices
- MongoDB Security Checklist
Author: Yevgen Somochkin Date: 2025 License: MIT

