I’ve been thinking a lot lately about what makes modern SaaS applications both scalable and secure. It’s not just about writing good code—it’s about building architectures that protect user data while handling growth. That’s why I want to share my approach to building secure multi-tenant applications using NestJS, Prisma, and PostgreSQL’s Row-Level Security. This combination gives you both developer productivity and enterprise-grade security.
Why does this matter? Every SaaS application needs to isolate customer data while maintaining performance. Have you considered how your database handles data separation between tenants?
Let me show you how to implement this properly. First, we set up our database with Row-Level Security. This ensures that each tenant can only access their own data at the database level, not just in the application code.
-- Enable RLS on tenant-scoped tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Now, how do we make this work with Prisma? We need to extend the Prisma client to automatically set the tenant context for each query.
// tenant-aware-prisma.service.ts
@Injectable()
export class TenantPrismaService extends PrismaService {
constructor(private tenantContext: TenantContextService) {
super();
}
get client() {
return this.$extends({
query: {
async $allOperations({ args, query }) {
const tenantId = this.tenantContext.getTenantId();
const [, result] = await this.$transaction([
this.$executeRaw`SET app.current_tenant_id = ${tenantId}`,
query(args),
]);
return result;
},
},
});
}
}
But what about authentication? We need JWT tokens that include tenant information. Here’s how we handle tenant-aware authentication:
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: any) {
return {
userId: payload.sub,
email: payload.email,
tenantId: payload.tenantId, // Critical for multi-tenancy
};
}
}
In our services, we always work through the tenant-scoped Prisma client. This ensures no query accidentally leaks data between tenants.
// projects.service.ts
@Injectable()
export class ProjectsService {
constructor(private prisma: TenantPrismaService) {}
async create(createProjectDto: CreateProjectDto) {
return this.prisma.client.project.create({
data: {
...createProjectDto,
// tenantId is automatically set by RLS context
},
});
}
async findAll() {
return this.prisma.client.project.findMany();
// Only returns projects for current tenant
}
}
Performance is crucial in multi-tenant applications. How do we ensure our queries remain fast with RLS? Proper indexing is key.
CREATE INDEX concurrently idx_projects_tenant_id
ON projects(tenant_id)
WHERE tenant_id IS NOT NULL;
Testing this architecture requires careful setup. We need to verify that data isolation actually works.
// projects.e2e-spec.ts
describe('Projects Multi-Tenancy', () => {
it('should not leak data between tenants', async () => {
// Create project for tenant A
await createProjectAsTenant('tenant-a', projectData);
// Try to access as tenant B
const response = await getProjectsAsTenant('tenant-b');
expect(response.body).toHaveLength(0);
});
});
Building secure multi-tenant applications requires thinking about data isolation at every layer. From database policies to application guards, each component must work together to maintain security boundaries. The patterns I’ve shown here provide a solid foundation that scales well while keeping your customers’ data safe.
What other security measures would you implement in a production environment? I’d love to hear your thoughts and experiences in the comments below. If you found this useful, please share it with other developers who might benefit from these patterns.