Back to blog

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

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.

Back to blog