Profile Picture

Yevgen`s official homepage

Yevgen Somochkin

"ESSO"

CTO Yieldy

Building the Future of AI & Automation | Full-stack & DevOps Architect | n8n | Scalable, Data-driven Web & Mobile Apps | Salesforce x2 certified

Home city logoHamburg, Germany

Profile Picture

Yevgen Somochkin

Need help?

Book a call

Software Engineer & Architect

Hamburg, Germany

Multi-Tenant SaaS Security: Best Practices Guide

Nov 13, 2025โ€ข
Web Development

A practical guide to building secure multi-tenant architecture based on a real SaaS application

๐Ÿ“‹ Table of Contents

  1. Introduction
  2. Architectural Principles
  3. Layer 1: Reverse Proxy (Nginx)
  4. Layer 2: Application Security
  5. Layer 3: Data Isolation
  6. Layer 4: Access Control (RBAC)
  7. Common Vulnerabilities
  8. 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

TypeDescriptionSecurityCost
Database-per-tenantSeparate DB for each tenantโญโญโญโญโญ๐Ÿ’ฐ๐Ÿ’ฐ๐Ÿ’ฐ๐Ÿ’ฐ๐Ÿ’ฐ
Schema-per-tenantSeparate schema in one DBโญโญโญโญ๐Ÿ’ฐ๐Ÿ’ฐ๐Ÿ’ฐ
Row-level isolationOne 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:

  1. Server block order is critical:

    • Regex patterns (
      ~^
      ) are processed in order of appearance
    • Tenant block MUST be FIRST
    • Otherwise
      es-1.localhost
      might match
      localhost
  2. Named captures:

    server_name ~^(?<subdomain>[^.]+)\.(localhost|c10so\.local)$;
    #           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
    #              Variable $subdomain now available
    
  3. 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:

HeaderProtects AgainstValue
X-Frame-OptionsClickjackingSAMEORIGIN = only own frames
X-Content-Type-OptionsMIME sniffingnosniff = don't guess content-type
X-XSS-ProtectionXSS (deprecated)mode=block = block suspicious content
Referrer-PolicyURL leakagestrict-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:

  1. Guard order matters:

    @UseGuards(JwtAuthGuard, TenantGuard)
    //         โ†‘ first       โ†‘ then
    
  2. TenantGuard requires authentication:

    • Always use with JwtAuthGuard
    • Otherwise
      request.user
      will be undefined
  3. 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:

  1. Always first parameter

    tenantId
    :

    async findAll(tenantId: string)
    //            โ†‘ required parameter
    
  2. 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
      });
    }
    
  3. 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-Slug
    headers 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

  1. Trust but Verify

    • Verify at each layer
    • Don't trust client-side data
    • Nginx headers > query params > client headers
  2. Defense in Depth

    • Nginx (network) + Guards (app) + DB (data)
    • One layer is not enough
    • Each layer is independent
  3. Explicit is Better

    • tenantId
      always explicit parameter
    • No implicit context or global state
    • Compile-time safety
  4. Fail Secure

    • Deny by default
    • Explicit whitelist > blacklist
    • Throw exception if no tenantId
  5. Audit Everything

    • Log all tenant switches
    • Track last activity
    • Monitor suspicious patterns

๐Ÿ“š Additional Resources


Author: Yevgen Somochkin Date: 2025 License: MIT