The hardest part of building a white-label SaaS was not the AI, the custom domains, or the billing. It was one login form.

I build VoiceDash, a white-label platform where agencies resell AI voice agents to their own clients under their own brand. On paper it is a voice product. In practice, the thing that kept me up at night was authentication, because a white-label product has a structural problem most apps never face: one login form has to serve two completely different kinds of person, and neither one is ever allowed to become the other.

This is the story of how I solved that with a single auth provider and one small discriminator field, and the edge-runtime gotcha that nearly broke the whole thing.

Two users who must never overlap

The hierarchy looks like this. An agency signs up. That agency is a Workspace. Inside the workspace, the agency's own staff are WorkspaceMembers. The agency then creates Clients, and each Client has its own ClientMembers, the actual end users logging into a portal that is branded to look like the agency built it themselves.

So there are two kinds of human hitting the login screen:

  • Agency users. My paying customers. They log in with email and password, and they administer everything: clients, agents, billing, branding.
  • Client members. My customers' customers. They log into a workspace-branded portal, they only ever see their own client's data, and they have no idea VoiceDash exists.

The invariant that matters more than anything else: a client member must never be able to reach an agency route, and must never see another client's data. If that breaks, one agency's customer can read another agency's customers. That is not a bug, that is the end of the business.

The tempting move is to build two auth systems. Two providers, two session shapes, two sets of middleware, two of everything. I did not want to maintain two of everything. So I built one.

One provider, one discriminator

VoiceDash runs on NextAuth with a single CredentialsProvider. The trick is a type field on the credentials, either "agency" or "client", and the authorize function branches on it:

async authorize(credentials) {
  if (!credentials) return null;
  const type = (credentials.type as string) || "agency";

  if (type === "client") {
    // client members log in by loginId OR email, password optional
    const loginId = credentials.loginId as string;
    if (!loginId) return null;
    let clientMember = await prisma.clientMember.findUnique({
      where: { loginId },
      include: { user: true, client: { include: { workspace: true } }, role: true },
    });
    // ...fall back to email lookup, verify password only if one is set...
    return {
      id: clientMember.user.id,
      clientId: clientMember.clientId,
      workspaceId: clientMember.client.workspaceId,
      role: clientMember.role?.name || "Member",
      type: "client",
    };
  }

  // agency users log in by email + password
  const user = await prisma.user.findUnique({ /* ... */ });
  // ...bcrypt compare...
  return {
    id: user.id,
    workspaceId: member?.workspaceId || null,
    role: member?.role || "MEMBER",
    type: "agency",
  };
}

Enter fullscreen mode Exit fullscreen mode

The two branches are genuinely different. Agency login is a plain email plus password. Client login accepts a loginId or an email, and the password is optional, because some agencies onboard their clients with no password at all and let them in by login ID. Two flows, two lookups, two shapes of returned user. But one provider, and both return an object carrying type.

That type is the entire security model in one word.

Bake it into the token, read it everywhere

A returned user object is not enough on its own. It has to survive into every future request. VoiceDash uses JWT sessions, so the discriminator gets written into the token once and read back on every request:

// jwt callback: user -> token, on sign in
token.type = (user as any).type;
token.workspaceId = (user as any).workspaceId;
token.clientId = (user as any).clientId;

// session callback: token -> session, on every request
(session as any).type = token.type;
(session as any).workspaceId = token.workspaceId;
(session as any).clientId = token.clientId;

Enter fullscreen mode Exit fullscreen mode

Now every piece of the app can ask one question, session.type, and know exactly who it is talking to. Data queries scope by workspaceId or clientId from the token, never from anything the client sends in the request. That last part is the whole game: the tenant boundary comes from the signed token, not from a URL parameter or a request body a client could tamper with.

The gate that enforces it

All of that would be theory without one place that actually turns people away. In this version of Next.js the middleware file is proxy.ts, and it is the single gate every request passes through:

// agency routes require an agency session
if (pathname.startsWith("/agency")) {
  if (!session) return NextResponse.redirect(new URL("/login", req.url));
  if ((session as any).type !== "agency") {
    return NextResponse.redirect(new URL("/client/agents", req.url));
  }
  return NextResponse.next();
}

Enter fullscreen mode Exit fullscreen mode

That type !== "agency" check is load bearing. A logged-in client member who types /agency/clients into the address bar does not get a 500 or a blank page, they get bounced back to their own portal. The same file also splits the root path: on the app subdomain the root sends you to your dashboard or the login page, while on the marketing domain the root renders the public landing page. One file, four decisions, all driven off the same token.

The gotcha that cost me an afternoon: edge cannot see your database

Here is the part I wish someone had told me. Middleware runs on the edge runtime. The edge runtime cannot import bcrypt, and it cannot import Prisma. If you try, your build does not fail politely, it fails at the exact moment the middleware tries to load, which feels like the auth itself is broken.

The fix is to split the config in two. There is an edge-safe auth.config.ts that holds only the callbacks, the pages, the session strategy, and an empty providers: [] array. It imports nothing from Node. Then a separate auth.ts spreads that config and adds the real CredentialsProvider with bcrypt and Prisma, and that file only ever runs in the Node runtime where those imports are legal.

// auth.config.ts, edge safe, imported by proxy.ts
export const authConfig: NextAuthConfig = {
  session: { strategy: "jwt" },
  providers: [], // real providers added in auth.ts
  callbacks: { /* jwt + session, no Node imports */ },
};

Enter fullscreen mode Exit fullscreen mode

The middleware imports the edge-safe config so it can read and validate the token without ever touching bcrypt or the database. The API routes import the full auth.ts. It feels like duplication the first time you see it. It is not. It is the boundary between "code that can run at the edge" and "code that needs a database," drawn on purpose.

What I would tell myself before starting

The discriminator field is the cheapest possible way to serve two audiences from one auth system, and it is also the single most dangerous line in the codebase. One missing type check is a privilege escalation, not a cosmetic bug. So I treat it the way I treat any invariant: I assume it will be forgotten somewhere, and the gate in proxy.ts exists precisely so that forgetting it in a page component does not become a breach.

If you are building anything where your customer has customers, this is the shape to reach for. One provider, one discriminator baked into the token, one gate that enforces it, and a clean edge and Node split so your middleware can validate identity without dragging your database to the edge.

If you want to see the finished product it powers, it is at voice-dash.com. But the auth model above is the part I am actually proud of, and it is the part that took the longest to get right.