·4 min de lecture
Authentification multi-tenant avec NextAuth v5
Implémentez un système d'authentification robuste avec rôles, permissions granulaires et isolation multi-tenant pour votre SaaS.
nextauthauthmulti-tenantsecurity
Le défi : auth + multi-tenant
L'authentification d'un SaaS multi-tenant est plus complexe qu'un simple login/password. Vous devez gérer :
- Organisations — chaque client a son espace isolé
- Rôles — ADMIN, MANAGER, COLLABORATOR, CLIENT...
- Permissions granulaires — "facturation:create", "rh:manage_leaves"
- Invitations — un admin invite ses collaborateurs
- Session — stocker l'org active dans le token
NextAuth v5 (Auth.js) : le bon choix
NextAuth v5 apporte le support natif de l'Edge Runtime et un système de callbacks puissant.
Configuration de base
// src/server/auth.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) {
// Validation email/password
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 }) {
// Injecter l'org et le rôle dans la 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;
},
},
});Matrice de permissions RBAC
Le pattern que j'utilise : une matrice statique typée qui est la source unique de vérité.
// 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",
// ... toutes les permissions
],
MANAGER: [
"facturation:read",
"facturation:create",
"clients:read",
// ... permissions limitées
],
COLLABORATOR: [
"clients:read",
// ... permissions minimales
],
CLIENT: [], // zéro permission interne
};Procédure tRPC avec permission
// 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 dans un router
export const rhRouter = createTRPCRouter({
approveLeave: requirePermission("rh:manage_leaves")
.input(z.object({ leaveId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Seuls ADMIN et MANAGER arrivent ici
}),
});Isolation multi-tenant : les pièges
Piège 1 : findUnique ne filtre pas par org
// DANGEREUX — pas de filtre organizationId
const invoice = await db.invoice.findUnique({
where: { id: input.id },
});
// CORRECT — findFirst avec double filtre
const invoice = await db.invoice.findFirst({
where: {
id: input.id,
organizationId: ctx.session.user.organizationId,
},
});Piège 2 : IDOR — ne jamais renvoyer FORBIDDEN
// MAUVAIS — révèle l'existence de la ressource
if (invoice.organizationId !== ctx.session.user.organizationId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// CORRECT — NOT_FOUND pour éviter la fuite d'information
const invoice = await db.invoice.findFirst({
where: { id: input.id, organizationId: ctx.session.user.organizationId },
});
if (!invoice) {
throw new TRPCError({ code: "NOT_FOUND" });
}Piège 3 : les sous-entités
// Une ligne de facture n'a pas d'organizationId direct
// → filtrer via le parent
const line = await db.invoiceLine.findFirst({
where: {
id: input.lineId,
invoice: {
organizationId: ctx.session.user.organizationId,
},
},
});Flow d'invitation
1. Admin crée une invitation → token unique généré
2. Email envoyé avec lien /accept-invite?token=xxx
3. Le nouvel utilisateur crée son compte
4. Il est automatiquement rattaché à l'organisation
5. Le rôle défini par l'admin est assigné
Checklist sécurité auth
- Tout
update/deleteaorganizationIddans lewhere -
findFirst(jamaisfindUnique) pour les ressources scopées - Répondre NOT_FOUND (jamais FORBIDDEN) pour les IDOR
- HMAC webhook vérifié avec
crypto.timingSafeEqual - Rate limiting sur le login
- Mots de passe hashés avec bcrypt/argon2
- Token d'invitation avec expiration
Conclusion
L'authentification multi-tenant est le fondement de tout SaaS sérieux. Les erreurs ici sont des failles de sécurité critiques — prenez le temps de bien l'implémenter dès le départ.
Le boilerplate HeartCo intègre tout ça out-of-the-box : 50+ permissions, 7 rôles, isolation automatique via Prisma $extends, et invitations par email.