The initial proof-of-concept for our SaaS platform was straightforward: one database per customer. It was simple to develop and guaranteed data isolation. This approach quickly became an operational and financial nightmare. Provisioning, migrating, and backing up hundreds of individual databases was untenable. The logical next step was a shared database, shared schema architecture, but this introduced a significant risk: a single bug in a WHERE
clause could expose one tenant’s data to another.
Our stack was Nuxt.js for the full-stack experience, leveraging its Nitro server engine for the API. Authentication was handled by JWT. The challenge was to enforce tenant isolation at the deepest possible layer, making it impossible for a developer to accidentally write a cross-tenant query. The solution couldn’t rely on developers remembering to add where: { tenantId: ... }
to every single database call. That’s a recipe for disaster in any real-world project.
The final architecture revolves around a simple principle: every authenticated API request is inextricably bound to a single tenant ID from the moment it enters the server until the database connection is closed. This is achieved by embedding the tenantId
within the JWT, verifying it via server middleware, and using that context to create a database client instance that is physically incapable of accessing data outside its designated tenant scope. We chose Prisma as our ORM and PostgreSQL as the database, leveraging a pattern that works well with Prisma’s extension capabilities.
The Foundation: Database Schema and Prisma Setup
Before writing any application code, the database schema must reflect the multi-tenant design. Every table that contains tenant-specific data must have a non-nullable tenantId
column. This column will be the foreign key to a central Tenant
table.
Here’s the Prisma schema. Note the required tenantId
on the User
and Project
models. The onDelete: Cascade
on the Tenant
model’s relations ensures that if a tenant is deleted, all their associated data is automatically cleaned up, a critical piece of data lifecycle management.
// schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(cuid())
name String @unique
users User[]
projects Project[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
email String @unique
password String // Hashed password
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
projects Project[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
model Project {
id String @id @default(cuid())
name String
description String?
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
ownerId String
owner User @relation(fields: [ownerId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
The indices on tenantId
are not optional; they are critical for performance. As the tables grow with data from thousands of tenants, queries without an index on tenantId
would result in slow, costly full-table scans.
Authentication Flow: Embedding Tenant Context into JWT
The authentication process is the entry point for establishing tenant context. When a user logs in, the server must verify their credentials and, upon success, issue a JWT containing not only their userId
but also their tenantId
.
This /server/api/auth/login.post.ts
endpoint demonstrates this. In a real-world project, password hashing and comparison (using a library like bcrypt
) is non-negotiable.
// /server/api/auth/login.post.ts
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
// A common mistake is not validating environment variables at startup.
// This causes cryptic runtime errors.
throw new Error('FATAL: JWT_SECRET environment variable is not set.');
}
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const { email, password } = body;
if (!email || !password) {
throw createError({
statusCode: 400,
statusMessage: 'Email and password are required.',
});
}
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
}
// In a real application, never store plain text passwords.
// This is a simplified example. Use bcrypt.compare in production.
// const passwordMatch = await bcrypt.compare(password, user.password);
const passwordMatch = password === user.password; // For demonstration only
if (!passwordMatch) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
}
// The core of the multi-tenant strategy: embed user and tenant IDs in the token.
const tokenPayload = {
userId: user.id,
tenantId: user.tenantId,
};
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '1h' });
// Use httpOnly cookies for security to prevent XSS attacks from stealing the token.
setCookie(event, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
return { status: 'ok' };
});
Enforcing Context: The Authentication Middleware
With the JWT issued, every subsequent request to a protected endpoint must be validated. A Nuxt server middleware is the perfect place for this. It intercepts all incoming API requests, verifies the token, and attaches the decoded payload to the request’s context (event.context
). This context becomes the single source of truth for the user’s identity and, most importantly, their tenant scope for the remainder of the request lifecycle.
// /server/middleware/auth.ts
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
// This defines the shape of the authenticated user context.
// Using TypeScript interfaces here is crucial for type safety downstream.
export interface AuthenticatedContext {
userId: string;
tenantId: string;
}
export default defineEventHandler((event) => {
// This middleware only runs on API routes that are not the auth endpoints.
const path = event.node.req.url || '';
if (path.startsWith('/api/') && !path.startsWith('/api/auth/')) {
const token = getCookie(event, 'auth_token');
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized: No token provided.',
});
}
try {
// Verify the token and extract the payload.
const decoded = jwt.verify(token, JWT_SECRET) as AuthenticatedContext;
// A critical check: ensure the payload has the required fields.
// A compromised token might lack these.
if (!decoded.userId || !decoded.tenantId) {
throw new Error('Invalid token payload');
}
// Attach the authenticated context to the event for later use in API handlers.
event.context.auth = decoded;
} catch (error) {
// Handle expired tokens, malformed tokens, etc.
console.error('JWT Verification Error:', error);
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized: Invalid token.',
});
}
}
});
Here’s the data flow at this stage:
sequenceDiagram participant Client participant NuxtServer as Nitro Engine participant AuthMiddleware participant APIEndpoint Client->>NuxtServer: GET /api/projects (with auth_token cookie) NuxtServer->>AuthMiddleware: Intercept request AuthMiddleware->>AuthMiddleware: Verify JWT from cookie alt Token is valid AuthMiddleware->>NuxtServer: Attach { userId, tenantId } to event.context NuxtServer->>APIEndpoint: Process request else Token is invalid AuthMiddleware-->>Client: Return 401 Unauthorized end
The Core Abstraction: A Tenant-Scoped Prisma Client
This is where the magic happens. We need to prevent developers from ever having to write prisma.project.findMany({ where: { tenantId: '...' } })
. We will use Prisma’s Client Extensions to create a “scoped” client. This extended client will automatically inject the tenantId
into every query for tenant-specific models.
First, let’s create a utility to manage our Prisma client instances.
// /server/utils/db.ts
import { PrismaClient } from '@prisma/client';
// A singleton instance of the base Prisma client.
const prisma = new PrismaClient();
export default prisma;
// This is the factory function for our scoped client.
export const getScopedPrismaClient = (tenantId: string) => {
if (!tenantId) {
// This check is a safeguard. In a correctly functioning system,
// this error should never be thrown.
throw new Error("Tenant ID must be provided to create a scoped Prisma client.");
}
return prisma.$extends({
query: {
// We extend the 'project' and 'user' models.
// Any model with a `tenantId` field should be listed here.
project: {
// Use '$allOperations' to apply the logic to find, create, update, delete, etc.
$allOperations({ model, operation, args, query }) {
// This logic modifies the query arguments before execution.
const newArgs = { ...args };
if (operation === 'create') {
// For 'create' operations, inject the tenantId into the data.
newArgs.data = { ...newArgs.data, tenantId };
} else {
// For all other operations (find, update, delete), inject the
// tenantId into the 'where' clause.
newArgs.where = { ...newArgs.where, tenantId };
}
return query(newArgs);
},
},
user: {
$allOperations({ args, query }) {
// You can also add more complex logic. For example, maybe users
// can see other users in their own tenant.
const newArgs = { ...args };
newArgs.where = { ...newArgs.where, tenantId };
return query(newArgs);
}
}
},
});
}
Now, API endpoints don’t interact with the global Prisma client. Instead, they get a scoped instance based on the authenticated context provided by the middleware. The esbuild
bundler used by Nitro will efficiently tree-shake this, ensuring our serverless functions remain lean.
An example API endpoint now looks incredibly clean and safe. Notice the complete absence of tenantId
in the business logic.
// /server/api/projects/index.get.ts
import { getScopedPrismaClient } from '~/server/utils/db';
import { AuthenticatedContext } from '~/server/middleware/auth';
export default defineEventHandler(async (event) => {
// The auth middleware guarantees `event.context.auth` exists and is valid.
const { tenantId } = event.context.auth as AuthenticatedContext;
// Get a Prisma client instance that is physically scoped to this tenant.
const scopedPrisma = getScopedPrismaClient(tenantId);
try {
// This query `findMany` has no `where: { tenantId: ... }`.
// The Prisma extension handles it automatically and transparently.
// It is impossible for this query to return projects from another tenant.
const projects = await scopedPrisma.project.findMany({
select: {
id: true,
name: true,
description: true,
},
});
return projects;
} catch (error) {
console.error('Failed to fetch projects:', error);
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error',
});
}
});
Testing for Isolation with Jest
Architecture without tests is just a theory. We must be able to prove that our data isolation works. We’ll use Jest to test our API endpoints. The key is to mock the authentication flow to simulate requests from different tenants.
First, the Jest setup needs to be configured to work with Nuxt’s server environment. We’ll need a testing utility to create mock events and simulate authenticated users.
// /tests/server/setup.ts (example setup)
import { AuthenticatedContext } from '~/server/middleware/auth';
// A helper to create a mock Nuxt event object for testing handlers.
export const createMockEvent = (authenticatedContext?: AuthenticatedContext) => {
const event = {
context: {},
node: {
req: {},
res: {},
},
// Add other event properties and methods as needed by your handlers.
};
if (authenticatedContext) {
event.context.auth = authenticatedContext;
}
return event;
};
Now, the test file for our projects endpoint. We need a test database that we can seed with data for multiple tenants.
// /tests/server/api/projects.get.spec.ts
import { PrismaClient } from '@prisma/client';
import projectHandler from '~/server/api/projects/index.get';
import { createMockEvent } from '../../utils/setup';
const prisma = new PrismaClient();
// Use Jest hooks to set up and tear down the database state for tests.
beforeAll(async () => {
// Clean the database before tests run
await prisma.project.deleteMany({});
await prisma.user.deleteMany({});
await prisma.tenant.deleteMany({});
// Seed data for Tenant A
const tenantA = await prisma.tenant.create({ data: { name: 'Tenant A' } });
const userA = await prisma.user.create({
data: { email: '[email protected]', password: '123', tenantId: tenantA.id }
});
await prisma.project.create({
data: { name: 'Project Alpha', ownerId: userA.id, tenantId: tenantA.id }
});
// Seed data for Tenant B
const tenantB = await prisma.tenant.create({ data: { name: 'Tenant B' } });
const userB = await prisma.user.create({
data: { email: '[email protected]', password: '123', tenantId: tenantB.id }
});
await prisma.project.create({
data: { name: 'Project Beta', ownerId: userB.id, tenantId: tenantB.id }
});
});
afterAll(async () => {
await prisma.$disconnect();
});
describe('GET /api/projects', () => {
it('should return projects only for the authenticated tenant (Tenant A)', async () => {
// Simulate a request from a user belonging to Tenant A
const tenantA = await prisma.tenant.findUnique({ where: { name: 'Tenant A' } });
const userA = await prisma.user.findUnique({ where: { email: '[email protected]' } });
const event = createMockEvent({ userId: userA!.id, tenantId: tenantA!.id });
const projects = await projectHandler(event as any);
// Assertions
expect(projects).toBeInstanceOf(Array);
expect(projects).toHaveLength(1);
expect(projects[0].name).toBe('Project Alpha');
});
it('should return projects only for the authenticated tenant (Tenant B)', async () => {
// Simulate a request from a user belonging to Tenant B
const tenantB = await prisma.tenant.findUnique({ where: { name: 'Tenant B' } });
const userB = await prisma.user.findUnique({ where: { email: '[email protected]' } });
const event = createMockEvent({ userId: userB!.id, tenantId: tenantB!.id });
const projects = await projectHandler(event as any);
// Assertions
expect(projects).toBeInstanceOf(Array);
expect(projects).toHaveLength(1);
expect(projects[0].name).toBe('Project Beta');
});
it('should return an empty array if the tenant has no projects', async () => {
// Create a new tenant with no projects
const tenantC = await prisma.tenant.create({ data: { name: 'Tenant C' } });
const userC = await prisma.user.create({
data: { email: '[email protected]', password: '123', tenantId: tenantC.id }
});
const event = createMockEvent({ userId: userC.id, tenantId: tenantC.id });
const projects = await projectHandler(event as any);
expect(projects).toEqual([]);
});
});
These tests provide a high degree of confidence that the data isolation boundary is holding. They programmatically verify that a user from one tenant cannot, under any circumstances, view or manipulate data belonging to another.
The architectural pattern of using a tenant-scoped context, established at the authentication boundary and enforced at the data access layer, is robust and scalable. The primary pitfall of this approach lies in its setup complexity; every data model and corresponding Prisma extension must be meticulously configured. Forgetting to add a new model to the scoped client extension would reintroduce the very security hole this pattern is designed to prevent.
Future iterations could explore PostgreSQL’s native Row-Level Security (RLS) as an even more foolproof alternative. With RLS, the data filtering logic resides within the database itself. The application middleware would set a session variable (e.g., SET app.current_tenant_id = '...'
) at the beginning of each transaction, and database policies would automatically enforce visibility rules. This moves the security boundary from the ORM layer down into the database kernel, making it virtually impossible for the application code to bypass. However, it introduces more complexity in database management and migrations.