{"slug": "the-hardest-part-of-a-white-label-saas-was-one-login-form", "title": "The Hardest Part of a White-Label SaaS Was One Login Form", "summary": "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.", "body_md": "The hardest part of building a white-label SaaS was not the AI, the custom domains, or the billing. It was one login form.\n\nI 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.\n\nThis 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.\n\nThe 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.\n\nSo there are two kinds of human hitting the login screen:\n\nThe 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.\n\nThe 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.\n\nVoiceDash runs on NextAuth with a single CredentialsProvider. The trick is a `type`\n\nfield on the credentials, either `\"agency\"`\n\nor `\"client\"`\n\n, and the `authorize`\n\nfunction branches on it:\n\n``` js\nasync authorize(credentials) {\n  if (!credentials) return null;\n  const type = (credentials.type as string) || \"agency\";\n\n  if (type === \"client\") {\n    // client members log in by loginId OR email, password optional\n    const loginId = credentials.loginId as string;\n    if (!loginId) return null;\n    let clientMember = await prisma.clientMember.findUnique({\n      where: { loginId },\n      include: { user: true, client: { include: { workspace: true } }, role: true },\n    });\n    // ...fall back to email lookup, verify password only if one is set...\n    return {\n      id: clientMember.user.id,\n      clientId: clientMember.clientId,\n      workspaceId: clientMember.client.workspaceId,\n      role: clientMember.role?.name || \"Member\",\n      type: \"client\",\n    };\n  }\n\n  // agency users log in by email + password\n  const user = await prisma.user.findUnique({ /* ... */ });\n  // ...bcrypt compare...\n  return {\n    id: user.id,\n    workspaceId: member?.workspaceId || null,\n    role: member?.role || \"MEMBER\",\n    type: \"agency\",\n  };\n}\n```\n\nThe two branches are genuinely different. Agency login is a plain email plus password. Client login accepts a `loginId`\n\nor 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`\n\n.\n\nThat `type`\n\nis the entire security model in one word.\n\nA 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:\n\n``` php\n// jwt callback: user -> token, on sign in\ntoken.type = (user as any).type;\ntoken.workspaceId = (user as any).workspaceId;\ntoken.clientId = (user as any).clientId;\n\n// session callback: token -> session, on every request\n(session as any).type = token.type;\n(session as any).workspaceId = token.workspaceId;\n(session as any).clientId = token.clientId;\n```\n\nNow every piece of the app can ask one question, `session.type`\n\n, and know exactly who it is talking to. Data queries scope by `workspaceId`\n\nor `clientId`\n\nfrom 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.\n\nAll 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`\n\n, and it is the single gate every request passes through:\n\n```\n// agency routes require an agency session\nif (pathname.startsWith(\"/agency\")) {\n  if (!session) return NextResponse.redirect(new URL(\"/login\", req.url));\n  if ((session as any).type !== \"agency\") {\n    return NextResponse.redirect(new URL(\"/client/agents\", req.url));\n  }\n  return NextResponse.next();\n}\n```\n\nThat `type !== \"agency\"`\n\ncheck is load bearing. A logged-in client member who types `/agency/clients`\n\ninto 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.\n\nHere 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.\n\nThe fix is to split the config in two. There is an edge-safe `auth.config.ts`\n\nthat holds only the callbacks, the pages, the session strategy, and an empty `providers: []`\n\narray. It imports nothing from Node. Then a separate `auth.ts`\n\nspreads 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.\n\n``` js\n// auth.config.ts, edge safe, imported by proxy.ts\nexport const authConfig: NextAuthConfig = {\n  session: { strategy: \"jwt\" },\n  providers: [], // real providers added in auth.ts\n  callbacks: { /* jwt + session, no Node imports */ },\n};\n```\n\nThe 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`\n\n. 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.\n\nThe 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`\n\ncheck 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`\n\nexists precisely so that forgetting it in a page component does not become a breach.\n\nIf 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.\n\nIf you want to see the finished product it powers, it is at [voice-dash.com](https://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.", "url": "https://wpnews.pro/news/the-hardest-part-of-a-white-label-saas-was-one-login-form", "canonical_source": "https://dev.to/nabeelbaghoor/the-hardest-part-of-a-white-label-saas-was-one-login-form-455n", "published_at": "2026-07-30 21:47:25+00:00", "updated_at": "2026-07-30 22:31:36.961445+00:00", "lang": "en", "topics": ["ai-products", "developer-tools"], "entities": ["VoiceDash", "NextAuth"], "alternates": {"html": "https://wpnews.pro/news/the-hardest-part-of-a-white-label-saas-was-one-login-form", "markdown": "https://wpnews.pro/news/the-hardest-part-of-a-white-label-saas-was-one-login-form.md", "text": "https://wpnews.pro/news/the-hardest-part-of-a-white-label-saas-was-one-login-form.txt", "jsonld": "https://wpnews.pro/news/the-hardest-part-of-a-white-label-saas-was-one-login-form.jsonld"}}