cd /news/ai-products/the-hardest-part-of-a-white-label-sa… · home topics ai-products article
[ARTICLE · art-81044] src=dev.to ↗ pub= topic=ai-products verified=true sentiment=· neutral

The Hardest Part of a White-Label SaaS Was One Login Form

A developer building VoiceDash, a white-label platform for AI voice agents, found that the hardest part was not the AI but a single login form serving two user types. The solution uses NextAuth with a single CredentialsProvider and a 'type' discriminator field to prevent client members from accessing agency routes or other clients' data.

read6 min views1 publishedJul 30, 2026

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.

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:

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.

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",
  };
}

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.

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;

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.

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();
}

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.

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 */ },
};

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.

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.

── more in #ai-products 4 stories · sorted by recency
── more on @voicedash 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-hardest-part-of-…] indexed:0 read:6min 2026-07-30 ·