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
es-1.localhostmight matchlocalhost
- 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
request.userwill be undefined
-
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
tenantId:async 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
X-Tenant-Slugheaders from client request - 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
tenantIdalways explicit parameter- 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




Comments
Loading comments...