A few weeks ago, I was working on yet another client dashboard. Each client needed their own private, secure space. The thought of managing separate databases or complex schemas for dozens, maybe hundreds of clients, felt like a future headache waiting to happen. That’s when I decided to fully figure out a better way to build multi-tenant applications. I wanted a system that was secure by design, scalable, and didn’t become a maintenance nightmare.
Today, I want to walk you through a method I’ve come to appreciate. It uses NestJS for structure, Prisma for smooth database work, and a powerful PostgreSQL feature called Row-Level Security (RLS) to keep everyone’s data separate. If you’re building a Software-as-a-Service (SaaS) product, this approach can be a real game-changer.
So, how do you ensure that a user from “Company A” can never, even by accident, see data from “Company B”? The answer lies at the database level.
Row-Level Security is a PostgreSQL feature that acts like a silent filter. You define policies, and the database enforces them on every single query, automatically. It’s like giving each tenant their own invisible, secure table within a shared database.
First, we need to tell the database who is making the request. We do this by setting a value for the current database session. Imagine this as putting on a security badge that says “Tenant: ABC Corp.” Every query that runs while wearing this badge will be filtered through that lens.
Here’s a basic example of enabling RLS and creating a policy:
-- First, enable RLS on the 'projects' table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create a policy that only allows access to rows where the tenant_id
-- matches the tenant we set for the current database connection.
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
With this policy in place, a SELECT * FROM projects; will only ever return projects for the specific tenant ID we set. The database does the heavy lifting.
The next challenge is getting that tenant context from our NestJS application down to the database. We need to know which tenant a request belongs to. A common and effective way is to use a custom header, like X-Tenant-ID, or to extract it from the user’s JWT token after they log in.
We manage this flow with a NestJS Guard and an Interceptor. The Guard checks the request and determines the tenant. The Interceptor then runs before our services and sets this tenant ID in the Prisma Client for that specific request.
This is where a key question might pop up: what stops someone from just setting a fake tenant ID in a request header? Great question! Our authentication system must be tightly coupled to tenant verification. A user’s JWT token should contain their userId and their tenantId. Our guard validates the token and extracts the tenantId from it, ignoring any headers. The header method is often used for initial tenant resolution (like from a subdomain) before login.
Here’s a simplified look at a Prisma service that sets the context:
// prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
private tenantId: string | null = null;
async onModuleInit() {
await this.$connect();
}
// A method to set the tenant for this request's instance
setTenantId(tenantId: string) {
this.tenantId = tenantId;
}
// We extend the client to inject the tenant context
getClient() {
if (!this.tenantId) {
throw new Error('Tenant ID has not been set for this request.');
}
// This is a critical step: we use $queryRaw to set a session variable
// that our RLS policies will check.
return this.$extends({
query: {
async $allOperations({ model, operation, args, query }) {
// First, set the context for this query
await this.$executeRaw`SELECT set_config('app.current_tenant_id', ${this.tenantId}, TRUE)`;
// Then, run the original query
return query(args);
},
},
});
}
}
In your services, you would use this.prisma.getClient().project.findMany(...). This ensures the tenant context is set fresh for every single database operation in that request.
This architecture touches everything. Creating a new user? You must associate them with a tenant. Querying projects or tasks? RLS and your context management ensure you only see what you should. The user experience is seamless; the security and separation are robust.
What about creating a new tenant itself, like when a new company signs up? This is a special operation that happens outside of the standard RLS flow, usually by a super-admin service with a direct database connection that can temporarily bypass RLS to create the tenant record and its first admin user.
Let’s wrap this up. Combining NestJS, Prisma, and PostgreSQL RLS creates a clean, secure foundation for your SaaS application. It moves critical security logic to the database, which is often the most reliable layer. Your application code becomes more about business logic and less about constantly checking permissions.
Building software is about solving real problems for people. A solid, secure multi-tenant foundation lets you focus on exactly that. I hope this guide gives you a clear path forward. If you found it helpful, please share it with a fellow developer who might be tackling the same challenge. I’d also love to hear about your experiences or questions in the comments below—what’s the biggest hurdle you’ve faced with multi-tenancy?