Skip to main content
All articles
4 min read

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/delete has organizationId in the where
  • findFirst (never findUnique) 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

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee