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.