Building Passwordless, Multi-Tenant Auth for an Identity Provider MVP

December 8, 2025 (7mo ago)

One of the more interesting problems I've worked on recently is the frontend for an Identity Provider (IdP) MVP — the piece of infrastructure other apps redirect to when they need someone to log in. The brief was blunt: no passwords as the primary flow, support multiple tenants from one codebase, and ship dashboards and audit logs that tenant admins could actually trust.

Here's what I learned building it with React 19, React Router v7, Formik/Yup, and TailwindCSS with Radix UI underneath.

Why passwordless first

Magic links and passkeys aren't just a UX nicety — they remove an entire class of support tickets (forgotten passwords) and an entire class of breach risk (credential stuffing against a password table you don't want to own). The tradeoff is that the happy path gets more steps, and every one of those steps can fail in a way a password field never does: an expired link, a passkey that isn't registered on the device the user is on right now, an email that lands in spam.

So the flow ended up being:

async function requestMagicLink(email: string, tenantId: string) {
  const res = await api.post("/auth/magic-link", { email, tenantId });
 
  if (!res.ok) {
    throw new AuthError(res.status === 429 ? "rate_limited" : "unknown");
  }
 
  // The link itself is single-use and tenant-scoped server-side —
  // the frontend's job is just to make the "check your email" state
  // feel intentional, not like the request vanished.
  return { status: "sent", expiresAt: res.data.expiresAt };
}

The detail that mattered most in practice wasn't the request — it was designing the "check your email" screen to show a countdown against expiresAt and a clearly-labeled resend button with its own cooldown. Without that, users just re-submitted the form repeatedly, which is exactly the rate-limit trigger you don't want them hitting.

Passkeys as a second, not a first, option

We offered WebAuthn-based passkeys as an upgrade path rather than the default, mainly because device support and user familiarity are still uneven. The registration flow itself is a fairly thin wrapper around the browser API — the actual complexity was in the fallback UX: detecting !window.PublicKeyCredential before ever showing the option, and making sure a user who registers a passkey on one device isn't locked out on another without a documented recovery path (in our case, that recovery path is the magic link, which is why it had to be solid regardless of how much we pushed passkeys).

Multi-tenancy changes more than the API calls

The part I underestimated going in was how much multi-tenancy touches things that have nothing to do with auth. Every route needed a tenant in scope before it could render anything meaningful, so tenant resolution had to happen above the router, not inside individual pages:

function TenantBoundary({ children }: { children: React.ReactNode }) {
  const { tenantId } = useParams();
  const { data: tenant, isLoading } = useTenant(tenantId);
 
  if (isLoading) return <TenantSkeleton />;
  if (!tenant) return <Navigate to="/not-found" replace />;
 
  return <TenantContext.Provider value={tenant}>{children}</TenantContext.Provider>;
}

That one boundary component saved a lot of repeated guard clauses further down the tree, and it made the audit log implementation simpler too — every mutation already had a tenant in context by the time it needed to be logged, so the audit entries never had to guess.

What I'd do differently

If I were starting over, I'd build the "check your email" and passkey-fallback states before touching the happy-path form. The form is the easy 80%; the interrupted, expired, and unsupported-device states are the 20% that actually determines whether the passwordless flow feels reliable or flaky. Get those right early and the rest of the auth surface follows.