js

How to Build Secure Multi-Tenant SaaS with NestJS and PostgreSQL RLS

Learn how to implement scalable, secure multi-tenancy in your SaaS app using NestJS and PostgreSQL Row-Level Security.

How to Build Secure Multi-Tenant SaaS with NestJS and PostgreSQL RLS

I’ve been building SaaS applications for years, and one question keeps coming up: how do you securely serve multiple customers from a single codebase? This isn’t just an academic exercise. If you’re building a software-as-a-service product, getting multi-tenancy right is the difference between a scalable business and a security nightmare. Today, I want to walk you through a practical, production-tested approach using NestJS and a powerful PostgreSQL feature.

Think about it: how does a platform like Slack or Notion keep your company’s data separate from everyone else’s? The answer often lies in a clever database design.

Let’s start with the foundation. In a multi-tenant system, every piece of data belongs to a specific customer, or “tenant.” We need to ensure that a user from “Company A” can never accidentally see data from “Company B.” There are a few ways to do this, but the most efficient for most SaaS products is to use a single database and separate data at the row level using a tenant_id column.

PostgreSQL has a built-in feature for this exact purpose called Row-Level Security (RLS). It’s like having a security guard inside your database that checks every single query. Instead of relying on your application code to remember to add WHERE tenant_id = ? to every query, RLS does it automatically. This drastically reduces the chance of a human error leading to a data leak.

Why is this approach so powerful? Because it moves the security enforcement as close to the data as possible. Your application becomes simpler, and your security model becomes more robust.

Here’s how we set up our database tables. Notice the tenant_id column on every table that holds tenant-specific data.

CREATE TABLE projects (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

But just having the column isn’t enough. We need to tell PostgreSQL to use it for security. This is where we enable RLS and create policies.

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

This policy is the magic. It says: “For any operation on the projects table, only show rows where the tenant_id matches the value we’ve stored in this database session.” The current_setting function is how we pass the tenant context from our application down to the database.

So, how does our NestJS application tell the database who the current tenant is? We need a reliable way to carry that information through every part of our code, from the moment a request comes in until the database query runs.

This is a perfect job for AsyncLocalStorage, a Node.js feature that lets us store data that’s available throughout the lifecycle of a single request. We’ll use the popular nestjs-cls package to make this easy.

First, we set up a global module to manage our request context.

// tenant-context.module.ts
import { ClsModule } from 'nestjs-cls';

@Global()
@Module({
  imports: [
    ClsModule.forRoot({
      global: true,
      middleware: { mount: true },
    }),
  ],
})
export class TenantContextModule {}

Now, we need a way to figure out which tenant is making a request. Usually, this comes from a JWT token or a subdomain in the URL. We’ll create a middleware to handle this.

// tenant.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly cls: ClsService) {}

  use(req: Request, res: Response, next: Function) {
    // Extract tenant ID from JWT or header
    const tenantId = req.headers['x-tenant-id'] as string;
    
    if (!tenantId) {
      throw new UnauthorizedException('Tenant context required');
    }

    // Store it in our async context
    this.cls.set('tenantId', tenantId);
    next();
  }
}

Have you ever wondered what happens during a database transaction? The connection is typically pulled from a pool. We need to ensure that every query on that connection knows the current tenant. We do this by setting a session variable right after we get a connection from the pool.

We can create a custom TypeORM connection wrapper to handle this automatically.

// tenant-aware-connection.ts
import { DataSource } from 'typeorm';
import { ClsService } from 'nestjs-cls';

export class TenantAwareDataSource extends DataSource {
  constructor(
    private readonly clsService: ClsService,
    options: DataSourceOptions,
  ) {
    super(options);
  }

  async createQueryRunner(mode: "master" | "slave" = "master"): Promise<QueryRunner> {
    const queryRunner = await super.createQueryRunner(mode);
    
    // Set the tenant ID for this connection's session
    const tenantId = this.clsService.get('tenantId');
    await queryRunner.query(`SET app.current_tenant_id = '${tenantId}'`);
    
    return queryRunner;
  }
}

With this setup, every new database connection automatically knows which tenant it’s serving. The RLS policies we defined earlier will now work perfectly.

But what about creating new data? We need to make sure our services and repositories automatically attach the correct tenant_id. We can build a base repository class to handle this.

// tenant-aware-repository.ts
import { Repository } from 'typeorm';
import { ClsService } from 'nestjs-cls';

export class TenantAwareRepository<Entity> extends Repository<Entity> {
  constructor(
    target: EntityTarget<Entity>,
    dataSource: DataSource,
    private readonly clsService: ClsService,
  ) {
    super(target, dataSource.manager);
  }

  async save(entity: DeepPartial<Entity>): Promise<Entity> {
    // Automatically inject tenantId if it's missing
    if (entity && 'tenantId' in entity) {
      const tenantId = this.clsService.get('tenantId');
      (entity as any).tenantId = tenantId;
    }
    return super.save(entity);
  }

  create(entityLike: DeepPartial<Entity>): Entity {
    const tenantId = this.clsService.get('tenantId');
    const entity = super.create(entityLike);
    (entity as any).tenantId = tenantId;
    return entity;
  }
}

Now, when a service calls this.projectRepository.save({ name: 'New Project' }), the tenantId is added automatically. This prevents developers from forgetting to set it, which is a common source of bugs.

Let’s look at a complete service example. Notice how clean it is—no tenant filtering logic cluttering the business logic.

// projects.service.ts
@Injectable()
export class ProjectsService {
  constructor(
    @InjectRepository(Project)
    private projectsRepository: TenantAwareRepository<Project>,
  ) {}

  async findAll(): Promise<Project[]> {
    // RLS ensures only this tenant's projects are returned
    return this.projectsRepository.find();
  }

  async create(createProjectDto: CreateProjectDto): Promise<Project> {
    const project = this.projectsRepository.create(createProjectDto);
    return this.projectsRepository.save(project);
  }
}

Isn’t it satisfying when the complex security part just… disappears? The service focuses on what it does: managing projects. The multi-tenancy is handled transparently by the infrastructure.

Of course, we need to test this thoroughly. How can we be sure that data never leaks between tenants? We write tests that simulate multiple tenants.

// projects.service.spec.ts
describe('ProjectsService', () => {
  it('should not leak data between tenants', async () => {
    // Simulate Tenant A's context
    clsService.set('tenantId', 'tenant-a-uuid');
    await service.create({ name: 'Tenant A Project' });

    // Simulate Tenant B's context
    clsService.set('tenantId', 'tenant-b-uuid');
    await service.create({ name: 'Tenant B Project' });

    // Query as Tenant B
    const projects = await service.findAll();
    
    // Should only see Tenant B's project
    expect(projects).toHaveLength(1);
    expect(projects[0].name).toBe('Tenant B Project');
  });
});

Performance is always a concern. Adding a tenant_id column to every table and query is great for security, but we must index it properly.

CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
CREATE INDEX idx_tasks_tenant_id_composite ON tasks(tenant_id, project_id, status);

Composite indexes are your friend here. If you often query for tasks within a specific project and status for a tenant, the composite index above will be far more efficient than separate indexes.

What about tenant onboarding? When a new company signs up, we need to create their tenant record and often set up some default data. This should be a transactional operation.

// tenant.service.ts
async onboardNewTenant(companyName: string, adminEmail: string): Promise<Tenant> {
  return this.dataSource.transaction(async (manager) => {
    // 1. Create the tenant record
    const tenant = manager.create(Tenant, { name: companyName });
    await manager.save(tenant);

    // Temporarily set context for this transaction
    await manager.query(`SET app.current_tenant_id = '${tenant.id}'`);

    // 2. Create the admin user for this tenant
    const adminUser = manager.create(User, {
      email: adminEmail,
      role: 'admin',
      // tenantId is automatically set via RLS context
    });
    await manager.save(adminUser);

    // 3. Create default projects
    const defaultProject = manager.create(Project, {
      name: 'Getting Started',
      ownerId: adminUser.id,
    });
    await manager.save(defaultProject);

    return tenant;
  });
}

This transactional approach ensures that if any step fails, nothing is created. We also temporarily set the tenant context within the transaction so that our RLS policies work even during the setup process.

There’s an important consideration for super-admins or system jobs that need to see across all tenants. We can create a special database role that bypasses RLS.

CREATE ROLE app_super_admin;
GRANT ALL ON ALL TABLES IN SCHEMA public TO app_super_admin;

-- For specific connections, disable RLS
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
-- Or connect as the super_admin role which is exempt from policies

In your application, you would use a separate database connection with these elevated privileges only for specific, isolated operations like generating cross-tenant analytics reports.

As your SaaS grows, you might wonder about scaling. The beauty of this RLS pattern is that it works whether you have 10 tenants or 10,000. The database handles the filtering efficiently, especially with proper indexes. If you eventually need to shard your database, the tenant_id is the perfect sharding key.

I find that the simplest solutions are often the most elegant. By leveraging PostgreSQL’s built-in RLS, we build our security on a rock-solid foundation. Our application code stays clean and focused on delivering features, not worrying about data leaks.

This pattern has served me well across multiple production SaaS applications. It gives customers the isolation they need while keeping the system manageable for developers. The initial setup requires some thought, but the long-term payoff in security and maintainability is immense.

What challenges have you faced with multi-tenant architectures? Have you tried different approaches? I’d love to hear about your experiences in the comments below. If you found this guide helpful, please share it with other developers who might be wrestling with these same architectural decisions.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: multi-tenancy,nestjs,postgresql,row-level-security,saas architecture



Similar Posts
Blog Image
Complete Event-Driven Microservices Architecture Guide: NestJS, RabbitMQ, and MongoDB Integration

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master CQRS, sagas, error handling & deployment strategies.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Applications

Learn how to seamlessly integrate Next.js with Prisma ORM for type-safe, full-stack web applications. Build powerful database-driven apps with enhanced developer experience.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn to integrate Next.js with Prisma ORM for type-safe, full-stack web applications. Build database-driven apps with seamless frontend-backend unity.

Blog Image
Complete Guide to Vue.js Pinia Integration: Master Modern State Management in 2024

Learn how to integrate Vue.js with Pinia for efficient state management. Master modern store-based architecture, improve app performance, and streamline development.

Blog Image
Build High-Performance GraphQL APIs: Apollo Server, DataLoader & Redis Caching Guide

Learn to build high-performance GraphQL APIs using Apollo Server, DataLoader, and Redis caching. Master N+1 problem solutions, advanced optimization techniques, and production-ready implementation strategies.

Blog Image
Build Production-Ready Event-Driven Microservices with NestJS, Redis Streams, and TypeScript Tutorial

Learn to build scalable event-driven microservices with NestJS, Redis Streams & TypeScript. Complete guide with error handling, testing & production deployment tips.