I’ve been building SaaS applications for over a decade, and one of the most common challenges I’ve faced is ensuring proper data isolation between customers. Just last month, I consulted on a project where tenant data leakage could have cost millions in compliance fines. That experience inspired me to create this practical guide to building secure multi-tenant systems.
Multi-tenancy means a single application serves multiple customers while keeping their data completely separate. Why does this matter? Because when you’re handling sensitive information for different organizations, a single data leak can destroy your business reputation overnight.
Let me show you how to build this properly. We’ll use NestJS for the framework, Prisma for database operations, and PostgreSQL Row-Level Security for iron-clad data isolation at the database level.
First, setting up our project foundation. I typically start with a clean NestJS installation and add the essential packages:
nest new multi-tenant-saas
npm install @prisma/client prisma @nestjs/config @nestjs/jwt
Now, here’s a question that might be on your mind: What makes database-level security better than application-level checks? The answer lies in defense depth. Even if your application code has bugs, the database itself prevents cross-tenant data access.
Our database design needs careful planning. Here’s how I structure the core tables:
-- Enable Row-Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
-- Create isolation policies
CREATE POLICY users_tenant_isolation ON users
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id'));
Notice how every table includes a tenant_id column? This becomes the foundation for our security model. The database policies ensure that users can only access records where the tenant_id matches their context.
Configuring Prisma requires some special attention. I’ve found that extending the Prisma client with tenant awareness saves countless headaches down the road:
// prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient {
async setTenantContext(tenantId: string) {
await this.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
}
}
Have you ever wondered how to automatically inject tenant context into every database query? That set_config call does exactly that. It tells PostgreSQL which tenant’s data we’re working with before executing any operation.
Authentication needs to be tenant-aware from the ground up. When a user logs in, we must verify they belong to the correct tenant and generate a JWT that includes their tenant context:
// auth.service.ts
async login(loginDto: LoginDto) {
const user = await this.prisma.user.findFirst({
where: {
email: loginDto.email,
tenantId: loginDto.tenantId
}
});
// Verify password and return JWT with tenant info
}
What happens if someone tries to access another tenant’s data? The JWT validation ensures they only get tokens for their own tenant, and the database policies block any cross-tenant queries.
Creating tenant-aware services involves some clever dependency injection. Here’s how I typically structure a service:
// organizations.service.ts
@Injectable()
export class OrganizationsService {
constructor(
private prisma: PrismaService,
@Inject(TENANT_CONTEXT) private tenantId: string
) {}
async createOrganization(data: CreateOrganizationDto) {
await this.prisma.setTenantContext(this.tenantId);
return this.prisma.organization.create({
data: { ...data, tenantId: this.tenantId }
});
}
}
Custom decorators and guards make the developer experience much cleaner. Instead of manually checking tenant context in every method, we can create reusable components:
// tenant.decorator.ts
export const Tenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user.tenantId;
}
);
Database migrations require special consideration in multi-tenant systems. Do you apply changes to all tenants simultaneously, or roll them out gradually? I prefer a controlled approach:
// migration.service.ts
async applyMigrationToTenant(tenantId: string, migrationScript: string) {
await this.prisma.setTenantContext(tenantId);
// Execute migration for specific tenant
}
Error handling becomes more complex when dealing with multiple tenants. A simple mistake could log sensitive tenant information. I always implement structured logging that includes tenant context but excludes actual data:
// error.filter.ts
catch(exception) {
this.logger.error({
message: exception.message,
tenantId: this.tenantId,
timestamp: new Date().toISOString()
});
}
Testing multi-tenant applications requires simulating different tenant contexts. I create test utilities that can switch between tenants during test execution:
// test.utils.ts
async function withTenant(tenantId: string, testFn: () => Promise<void>) {
await prismaService.setTenantContext(tenantId);
await testFn();
}
Performance optimization becomes crucial as you scale. Have you considered how database indexes affect multi-tenant queries? Compound indexes that include tenant_id dramatically improve performance:
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
Security best practices extend beyond the database. I always recommend regular security audits, proper API rate limiting per tenant, and comprehensive monitoring of access patterns.
Alternative approaches exist, like separate databases per tenant, but they introduce operational complexity. The RLS approach gives you security without the overhead of managing multiple database instances.
In my experience, the key to successful multi-tenant applications is defense in depth. Application checks, database policies, and proper authentication all work together to create a robust system.
I’d love to hear about your experiences with multi-tenant architectures. What challenges have you faced? Share your thoughts in the comments below, and if you found this guide helpful, please like and share it with your team. Your feedback helps me create better content for everyone.