Multi-Tenant Authentication with NextAuth v5
Implement robust authentication with roles, granular permissions, and multi-tenant isolation for your SaaS, using NextAuth v5.
The challenge: auth + multi-tenant
Authenticating a multi-tenant SaaS is more complex than a plain login/password flow. You have to handle:
- Organizations: each customer gets an isolated workspace
- Roles: ADMIN, MANAGER, COLLABORATOR, CLIENT...
- Granular permissions, like
"facturation:create"or"rh:manage_leaves" - Invitations: an admin invites their teammates
- Sessions: storing the active organization in the token
NextAuth v5 (Auth.js): the right choice
NextAuth v5 brings native Edge Runtime support and a powerful callbacks system.
Base configuration
// src/server/auth/config.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
export const { auth, signIn, signOut, handlers } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
Google({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
}),
Credentials({
async authorize(credentials) {
// Email/password validation
const user = await db.user.findUnique({
where: { email: credentials.email },
include: { organization: true },
});
if (!user || !(await verify(credentials.password, user.password))) {
return null;
}
return user;
},
}),
],
callbacks: {
async session({ session, token }) {
// Inject the org and role into the session
session.user.id = token.sub!;
session.user.organizationId = token.organizationId;
session.user.role = token.role;
return session;
},
async jwt({ token, user }) {
if (user) {
token.organizationId = user.organizationId;
token.role = user.role;
}
return token;
},
},
});RBAC permission matrix
The pattern used here: a static, typed matrix that is the single source of truth.
// src/lib/permissions/matrix.ts
export type Permission =
| "facturation:read"
| "facturation:create"
| "facturation:edit"
| "clients:read"
| "clients:create"
| "rh:manage_leaves";
// ... 50+ permissions
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
ADMIN: [
"facturation:read",
"facturation:create",
"facturation:edit",
"clients:read",
"clients:create",
"rh:manage_leaves",
// ... every permission
],
MANAGER: [
"facturation:read",
"facturation:create",
"clients:read",
// ... a limited set
],
COLLABORATOR: [
"clients:read",
// ... a minimal set
],
CLIENT: [], // zero internal permissions
};A permission-gated tRPC procedure
// src/server/api/trpc.ts
export function requirePermission(permission: Permission) {
return staffProcedure.use(({ ctx, next }) => {
if (!hasPermission(ctx.session.user.role, permission)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next({ ctx });
});
}
// Usage inside a router
export const rhRouter = createTRPCRouter({
approveLeave: requirePermission("rh:manage_leaves")
.input(z.object({ leaveId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Only ADMIN and MANAGER ever reach this line
}),
});Multi-tenant isolation: the traps
Trap 1: findUnique doesn't filter by organization
// DANGEROUS: no organizationId filter
const invoice = await db.invoice.findUnique({
where: { id: input.id },
});
// CORRECT: findFirst with both fields in the where
const invoice = await db.invoice.findFirst({
where: {
id: input.id,
organizationId: ctx.session.user.organizationId,
},
});Trap 2: IDOR, never return FORBIDDEN
// BAD: reveals the resource exists
if (invoice.organizationId !== ctx.session.user.organizationId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// CORRECT: NOT_FOUND, to avoid the information leak
const invoice = await db.invoice.findFirst({
where: { id: input.id, organizationId: ctx.session.user.organizationId },
});
if (!invoice) {
throw new TRPCError({ code: "NOT_FOUND" });
}Trap 3: nested entities
// An invoice line has no direct organizationId
// → filter through the parent
const line = await db.invoiceLine.findFirst({
where: {
id: input.lineId,
invoice: {
organizationId: ctx.session.user.organizationId,
},
},
});Invitation flow
1. Admin creates an invitation → a unique token is generated
2. An email is sent with a /accept-invite?token=xxx link
3. The new user creates their account
4. They're automatically attached to the organization
5. The role set by the admin is assigned
Auth security checklist
- Every
update/deletehasorganizationIdin thewhere -
findFirst(neverfindUnique) for scoped resources - Respond with NOT_FOUND (never FORBIDDEN) for IDOR cases
- Webhook HMAC verified with
crypto.timingSafeEqual - Rate limiting on login
- Passwords hashed with bcrypt/argon2
- Invitation tokens with an expiration
Conclusion
Multi-tenant authentication is the foundation of any serious SaaS. Mistakes here are critical security flaws: take the time to get it right from the start.
The HeartCo boilerplate ships all of this out of the box: 50+ permissions, 7 roles, automatic isolation via Prisma $extends, and email invitations.
Go further
Related articles
Testing Multi-Tenant Isolation in a SaaS
Twelve test families pulled from real code: IDOR, webhooks, rate limiting, sessions. What they prove, and what they don't.
ReadMulti-Tenant Isolation with Prisma and tRPC: What the Automatic Filter Covers, and What Doesn't
Isolate each organization's data in Next.js with Prisma $extends and tRPC: what the automatic filter covers, and what stays explicit.
ReadTesting a Multi-Tenant SaaS: 7 Essential Vitest Patterns
From tenant isolation to RBAC permission checks, the test patterns that make a B2B SaaS genuinely reliable. With Vitest, tRPC, and Prisma.
ReadReady to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.