Authentication
Sep 2, 2025

Passwordless authentication with React JS

Kuntal Banerjee
Founding Engineer

TL;DR:

  • Hook-based state machine: usePasswordlessAuth encapsulates all phases (idle → sending → codeSent → verifying → authenticated → error) so React components stay declarative and reusable.
  • Server-first security: Scalekit issues and validates cryptographically signed tokens with expiry, origin binding, and resend invalidation; secrets never touch the client.
  • Reusable components: EmailForm, OtpForm, and MagicLinkHandler consume hook state directly, handling edge cases (expired codes, double clicks, redirects) without ad-hoc logic.
  • Persistent context: AuthProvider hydrates sessions on mount, persists state across reloads, and centralizes access so routes only check phase === "authenticated".
  • Resilient UX: explicit loading states, error boundaries, auto-submit OTPs, and countdown timers prevent frozen screens and remove friction for users.
  • Type-safe + testable: TypeScript enforces complete phase coverage, and Vitest + Testing Library replay real failure modes (resends, expiry, refreshes) to prevent regressions.

A mid-size product team decided to modernize their login experience by replacing passwords with email-based links and short codes. The idea seemed simple: remove the burden of forgotten passwords while giving users a smoother way to sign in. But as they started building it in React, the complexity quickly surfaced. Multiple forms had to be managed, asynchronous calls collided with redirects, and state became fragile when users switched tabs or devices. What began as a small feature turned into tangled code that was hard to maintain and nearly impossible to reuse across different parts of the app.

The real issue was not in the authentication methods themselves, but in how the frontend managed them. Scalekit provides secure APIs for issuing and validating credentials, but without a clear way to structure the React side, teams often end up reinventing state machines and temporary fixes. By shifting to a hook-based pattern and centralizing authentication state in context, React developers can treat login as a reusable set of components rather than scattered logic. Hooks handle inputs, link detection, and verification, while context ensures the app always knows whether a user is signed in.

This guide shows how to build production-ready React components for passwordless authentication using the Scalekit SDK. You will learn how to create a custom authentication hook, implement email and code input components with validation, handle magic link redirects automatically, persist authentication state with context, and integrate loading states, error boundaries, and type safety. The result is a complete authentication flow that works reliably across projects and scales without added complexity. You can explore the full working project that this guide is based on in our GitHub repository: React Passwordless Authentication Example.

High level passwordless auth flow

At a high level, passwordless authentication is just: collect email → issue credential → verify → authenticate.

Why passwordless authentication matters

Passwords remain the default login method in most applications, yet they introduce constant problems. From a security standpoint, static secrets stored in databases are prime targets for leaks and credential stuffing attacks. From a user standpoint, password resets are one of the most common sources of friction, leading to weak adoption or churn. Engineering teams often spend time building reset flows, implementing rate limiting, and monitoring suspicious login activity, all of which divert attention from product work.

Passwordless authentication eliminates stored secrets by using short-lived, verifiable credentials. The two most common approaches are magic links and one-time passcodes (OTP). A magic link encodes a signed, expiring token inside a URL. When the user clicks it, the client submits the token back to the server for verification. An OTP works the same way but delivers a short numeric code instead of a link, which provides a fallback when links expire or when users switch devices mid-session. Both methods reduce exposure because tokens expire quickly and cannot be reused.

For frontend teams, the implications are significant. Login becomes a matter of collecting an email, handling a redirect, and submitting either a link token or a numeric code. There is no need for password strength checks, hashing libraries, or reset UIs. What’s left is a cleaner surface area for React developers to implement reusable components that guide the user through these flows, while the backend enforces the actual security guarantees.

The role of Scalekit in the architecture

Passwordless authentication only works if the issuing and validation of credentials are secure. In a typical custom setup, teams need to build their own token generator, manage expirations, prevent replay attacks, and handle resend logic. These rules live on the server and must be enforced consistently across environments. Any gap in implementation, such as weak signing, missing expiry checks, or improper storage, turns into a critical vulnerability.

Scalekit provides an API-first model for handling this layer. It issues cryptographically signed tokens, enforces expiry windows, and validates them without exposing secrets to the client. Features like same-browser binding prevent tokens from being replayed on different devices, and resend handling ensures that only the most recent link or code is valid. Instead of embedding this logic inside a React app, developers call Scalekit’s endpoints from the server, which keeps secrets confined to environment variables.

With this separation, React can focus entirely on the frontend flow: collecting the user’s email, detecting when a magic link is clicked, verifying a code, and maintaining authentication state. Scalekit guarantees that the underlying tokens are valid, short-lived, and resistant to replay. This division of responsibilities makes the architecture both cleaner and more secure.

Division of responsibilities

The frontend only orchestrates UI and state, while Scalekit enforces token security on the server

Setting up a fresh React + Scalekit passwordless project

You don’t need to start from an existing repo. The flow in this guide can be built from scratch using a React frontend and a TypeScript Express backend that talks to Scalekit.

1. Initialize the project structure

mkdir react-scalekit-passwordless cd react-scalekit-passwordless mkdir client server

2. Create the frontend (React 18 + Vite + TypeScript):

cd client npm create vite@latest . -- --template react-ts npm install cd ..

3. Create the backend (Express + TypeScript):

cd server npm init -y npm install express cors dotenv jsonwebtoken npm install -D typescript ts-node @types/node @types/express npx tsc --init cd ..

4. Install Scalekit SDK in the backend

cd server npm install @scalekit-sdk/node cd ..

5. Set up environment variables in server/.env:

SCALEKIT_ENVIRONMENT_URL=... SCALEKIT_CLIENT_ID=... SCALEKIT_CLIENT_SECRET=...

6. Run both servers in parallel

# Terminal 1 (backend) cd server && npx ts-node src/index.ts # Terminal 2 (frontend) cd client && npm run dev

7. Open the app at http://localhost:5173.

At this point you’ll have a blank canvas: a React app ready to integrate the usePasswordlessAuth hook, and an Express backend wired to Scalekit to issue and verify credentials. The sections that follow will fill in both sides step by step.

Designing React for authentication flows

React applications excel at building interactive interfaces, but authentication flows often stretch the limits of ad-hoc state management. A login flow requires tracking form inputs, pending requests, redirects from magic links, verification of codes, and the user’s final authenticated state. If each of these is handled directly inside components, the result is duplicated logic, race conditions, and components that are difficult to reuse.

The React model already provides the right primitives to manage this complexity. Hooks encapsulate logic like sending login requests or validating a one-time code, so components stay focused on rendering. Context supplies a shared authentication state across the tree, ensuring every part of the app can react to sign-in or sign-out without prop drilling. Together, hooks and context form a reliable pattern: hooks handle side-effects and state transitions, while context exposes a single source of truth for whether a user is authenticated.

For passwordless authentication, this pattern is especially powerful. The same hook can issue both magic links and OTPs, while context keeps track of whether a user has completed verification. React components simply consume these hooks and context values, making the login experience portable across pages or even projects. This approach avoids fragile state machines and keeps authentication logic isolated, tested, and reusable.

A minimal usePasswordlessAuth hook (state machine+handlers)

This core excerpt makes the state machine explicit and shows the exact handlers the components consume. It mirrors the phases used throughout the article and keeps external concerns (cookies, storage) behind the hook boundary.

usePasswordlessAuth State Machine

The hook encodes the login process as a state machine with predictable transitions.

// client/src/auth/usePasswordlessAuth.min.ts (excerpt) import { useCallback, useEffect, useState } from "react"; type Phase = "idle" | "sending" | "codeSent" | "verifying" | "authenticated" | "error"; interface SendResult { authRequestId: string; expiresAt: number; // seconds since epoch passwordlessType: "OTP" | "LINK" | "LINK_OTP"; } export function usePasswordlessAuth({ apiBase = "/api" }: { apiBase?: string } = {}) { const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); const [email, setEmail] = useState(""); const [sendResult, setSendResult] = useState(null); const [now, setNow] = useState(() => Date.now()); // 1) Send (issues OTP or link depending on backend config) const send = useCallback(async (inputEmail: string) => { setPhase("sending"); setError(null); try { const r = await fetch(`${apiBase}/auth/passwordless/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: inputEmail }), }); const body = await r.json(); if (!r.ok) throw new Error(body.error || "SEND_FAIL"); setEmail(inputEmail); setSendResult({ authRequestId: body.authRequestId, expiresAt: body.expiresAt, passwordlessType: body.passwordlessType, }); setPhase("codeSent"); // for LINK-only, UI may immediately show success copy try { localStorage.setItem("pl.sendResult", JSON.stringify(body)); } catch {} try { localStorage.setItem("pw_auth_request_id", body.authRequestId); } catch {} } catch (e: any) { setError(e.message || String(e)); setPhase("error"); } }, [apiBase]); // 2) Verify OTP const verifyCode = useCallback(async (code: string) => { if (!sendResult) return; setPhase("verifying"); setError(null); try { const r = await fetch(`${apiBase}/auth/passwordless/verify-code`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ authRequestId: sendResult.authRequestId, code }), }); const body = await r.json(); if (!r.ok) throw new Error(body.error || "VERIFY_FAIL"); try { localStorage.setItem("pl.sessionEmail", body.email); } catch {} setPhase("authenticated"); } catch (e: any) { setError(e.message || String(e)); setPhase("error"); } }, [apiBase, sendResult]); // 3) Verify Magic Link const handleMagicLink = useCallback(async (linkToken: string, authRequestId?: string) => { (handleMagicLink as any)._active ??= null; if ((handleMagicLink as any)._active === linkToken && (phase === "verifying" || phase === "authenticated")) return; (handleMagicLink as any)._active = linkToken; setPhase("verifying"); setError(null); try { const r = await fetch(`${apiBase}/auth/passwordless/verify-link`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ linkToken, authRequestId: authRequestId ?? localStorage.getItem("pw_auth_request_id") ?? undefined, }), }); const body = await r.json(); if (!r.ok) throw new Error(body.error || "VERIFY_FAIL"); try { localStorage.setItem("pl.sessionEmail", body.email); } catch {} setPhase("authenticated"); } catch (e: any) { (handleMagicLink as any)._active = null; // allow retry setError(e.message || String(e)); setPhase("error"); } }, [apiBase, phase]); // 4) Session hydration on mount useEffect(() => { (async () => { try { const r = await fetch(`${apiBase}/auth/passwordless/session`, { credentials: "include" }); const body = await r.json(); if (body?.authenticated) setPhase("authenticated"); } catch { /* ignore */ } })(); }, [apiBase]); // 5) OTP expiry countdown + auto-clear useEffect(() => { if (!sendResult) return; const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [sendResult]); const timeLeft = sendResult ? Math.max(0, sendResult.expiresAt * 1000 - now) / 1000 : 0; useEffect(() => { if (sendResult && timeLeft === 0 && phase === "codeSent") { try { localStorage.removeItem("pl.sendResult"); } catch {} setSendResult(null); setPhase("idle"); } }, [sendResult, timeLeft, phase]); // 6) Reset and Logout helpers const reset = useCallback(() => { setPhase("idle"); setSendResult(null); setEmail(""); setError(null); try { localStorage.removeItem("pl.sendResult"); localStorage.removeItem("pw_auth_request_id"); } catch {} }, []); const logout = useCallback(async () => { try { await fetch(`${apiBase}/auth/passwordless/logout`, { method: "POST", credentials: "include" }); } catch {} reset(); try { localStorage.removeItem("pl.sessionEmail"); } catch {} }, [apiBase, reset]); const sessionEmail = (() => { try { return localStorage.getItem("pl.sessionEmail"); } catch { return null; } })(); return { phase, error, email: sessionEmail || email, sendResult, timeLeft, send, verifyCode, handleMagicLink, reset, logout }; }

Why this helps the reader: it crystallizes the state machine (idle → sending → codeSent → verifying → authenticated → idle/error), shows the exact network boundaries, and demonstrates persistence, countdown, and deduping in a compact, production‑ready pattern.

Building an email input component with clear validation and resilient UX

Email capture is the first place our SaaS team’s flow used to break. The form must trim input, validate format, prevent duplicates while sending, and surface backend errors without leaking whether an email exists. In your project, EmailForm.tsx calls useAuth().send(email) and persists the authRequestId via the hook, so the component’s job is strict input handling and a clean pending state that maps to the hook’s phase: "sending".

Validation rules that avoid brittle edge cases

  • Format validation uses a pragmatic regex (not full RFC), after trim().toLowerCase().
  • Disabled submit during phase === "sending" prevents double posts.
  • Generic success copy (“Check your email for a link or code”) avoids account enumeration.
  • Accessible markup uses a <label>, aria-invalid, and aria-describedby for errors.
  • Error surfacing renders the error from the hook without exposing server internals.
// client/src/components/EmailForm.tsx import { FormEvent, useState } from "react"; import { useAuth } from "../auth/AuthProvider"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; // pragmatic, user-friendly export default function EmailForm() { const { phase, error, send } = useAuth(); const [value, setValue] = useState(""); function onSubmit(e: FormEvent) { e.preventDefault(); const email = value.trim().toLowerCase(); if (!EMAIL_RE.test(email)) return; // show inline hint instead of posting void send(email); // POST /auth/passwordless/send } const pending = phase === "sending"; return (
setValue(e.target.value)} aria-invalid={!!error} aria-describedby={error ? "email-error" : undefined} disabled={pending} /> {error && ( )} {/* Success hint lives in the next screen (codeSent) to avoid enumeration */}
); }

How this tie back to the scenario

Form duplication and race conditions were pain points for the team. This component routes all network work to the hook, reads a single phase flag to gate UI, and never guesses server state. The result is a reusable, portable form that behaves identically on every page and supports both magic links and codes without branching logic in the component.

Implementing a resilient one-time code verification component with auto-submit

OTP verification used to be the team’s most brittle screen. Focus jumps, paste glitches, and expired codes stranded users. This version treats verification as a pure UI over the hook’s codeSent → verifying → authenticated phases. The component manages six digit boxes, paste-to-fill, auto-submit on completion, and a live countdown sourced from the hook’s timeLeft. The form never guesses server state; it only calls verifyCode(code) and renders phase and error.

Behavior the component guarantees

  • Six inputs accept only digits; backspace moves focus left; typing moves right.
  • Pasting a full code distributes digits across boxes in one step.
  • Auto-submit triggers once all boxes are filled; manual submit remains available.
  • Countdown reads timeLeft; an expired code disables inputs and shows recovery.
  • Accessibility uses labels, aria-invalid, and aria-live for the timer.
// client/src/components/OtpForm.tsx import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "../auth/AuthProvider"; const CODE_LENGTH = 6; export default function OtpForm() { const { phase, error, timeLeft, verifyCode, email, reset } = useAuth(); const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill("")); const inputsRef = useRef<Array<HTMLInputElement | null>>([]); const code = useMemo(() => digits.join(""), [digits]); const pending = phase === "verifying"; const active = phase === "codeSent"; const expired = active && timeLeft === 0; // Auto-submit when all boxes are filled (and not already verifying) useEffect(() => { if (active && code.length === CODE_LENGTH && !pending) { void verifyCode(code); } }, [active, pending, code, verifyCode]); function onSubmit(e: FormEvent) { e.preventDefault(); if (code.length === CODE_LENGTH && active && !pending) { void verifyCode(code); } } function focusIndex(i: number) { inputsRef.current[i]?.focus(); inputsRef.current[i]?.select(); } function onChange(i: number, v: string) { if (!active || pending || expired) return; const char = v.slice(-1); if (!/^\d$/.test(char)) { // Non-digit typed: ignore but keep focus inputsRef.current[i]?.select(); return; } setDigits((prev) => { const next = [...prev]; next[i] = char; return next; }); if (i < CODE_LENGTH - 1) focusIndex(i + 1); } function onKeyDown(i: number, e: React.KeyboardEvent<HTMLInputElement>) { if (e.key === "Backspace") { e.preventDefault(); setDigits((prev) => { const next = [...prev]; if (next[i]) { next[i] = ""; return next; } if (i > 0) { next[i - 1] = ""; focusIndex(i - 1); } return next; }); return; } if (e.key === "ArrowLeft" && i > 0) { e.preventDefault(); focusIndex(i - 1); } if (e.key === "ArrowRight" && i < CODE_LENGTH - 1) { e.preventDefault(); focusIndex(i + 1); } } function onPaste(i: number, e: React.ClipboardEvent<HTMLInputElement>) { if (!active || pending || expired) return; const text = e.clipboardData.getData("text").replace(/\D/g, ""); if (!text) return; e.preventDefault(); const chars = text.slice(0, CODE_LENGTH).split(""); setDigits((prev) => { const next = [...prev]; for (let j = 0; j < CODE_LENGTH && (i + j) < CODE_LENGTH; j++) { next[i + j] = chars[j] ?? next[i + j]; } return next; }); const last = Math.min(i + chars.length - 1, CODE_LENGTH - 1); focusIndex(last); } // Clear UI digits if the code expired or screen is reset useEffect(() => { if (!active || expired) { setDigits(Array(CODE_LENGTH).fill("")); } }, [active, expired]); return ( <form onSubmit={onSubmit} className="otp-form" noValidate> <div className="otp-header"> <label htmlFor="otp-0" className="block"> Enter the 6-digit code </label> {email && <p className="hint">Sent to <strong>{email}</strong></p>} </div> <div className="otp-grid" role="group" aria-label="One-time code"> {Array.from({ length: CODE_LENGTH }).map((_, i) => ( <input key={i} id={`otp-${i}`} ref={(el) => (inputsRef.current[i] = el)} inputMode="numeric" autoComplete="one-time-code" pattern="\d*" maxLength={1} value={digits[i]} onChange={(e) => onChange(i, e.target.value)} onKeyDown={(e) => onKeyDown(i, e)} onPaste={(e) => onPaste(i, e)} disabled={!active || pending || expired} aria-invalid={!!error} className="otp-cell" /> ))} </div> <div className="otp-actions"> <button type="submit" disabled={!active || pending || code.length !== CODE_LENGTH || expired}> {pending ? "Verifying..." : "Verify code"} </button> <span aria-live="polite" className="countdown"> {active && !expired ? `Expires in ${Math.max(0, Math.floor(timeLeft))}s` : null} </span> </div> {error && <p role="alert" className="error">{error}</p>} {expired && ( <div className="expired"> <p>The code expired. Request a new link or code.</p> <button type="button" onClick={reset}>Start over</button> </div> )} </form> ); }

How this ties back to the scenario

The team’s earlier OTP screen chained timers and disabled inputs unpredictably after redirects. This version lets the hook own the countdown and expiry cleanup, while the component only reflects active/pending/expired. Paste-to-fill fixes “copy-from-email” friction, and auto-submit eliminates the extra click, two small changes that removed most support tickets around code entry.

Detecting magic link tokens on redirect and verifying safely

Magic link redirects are the most timing‑sensitive step our team faced. Redirects land the user on an app route with a link_token (and sometimes auth_request_id) in the query string. The UI should verify immediately, avoid double posts when users re‑click the email, and then clean up the URL. Your hook already implements deduping and same‑browser binding via the persisted request id, so the component’s role is to extract parameters, call handleMagicLink, and render progress or recovery states.

Magic link verification flow

Magic links are automatically consumed on redirect, deduped, and the URL is cleaned up to prevent replay

Implementation that is resilient and idempotent

// client/src/components/MagicLinkHandler.tsx import { useEffect } from "react"; import { useAuth } from "../auth/AuthProvider"; export default function MagicLinkHandler() { const { phase, error, handleMagicLink, reset } = useAuth(); useEffect(() => { const url = new URL(window.location.href); const linkToken = url.searchParams.get("link_token"); const authReqId = url.searchParams.get("auth_request_id") ?? undefined; if (!linkToken) return; // Kick off verification immediately; hook dedupes repeated clicks void handleMagicLink(linkToken, authReqId); // Clean up query string to prevent re-verification on refresh url.searchParams.delete("link_token"); url.searchParams.delete("auth_request_id"); window.history.replaceState({}, "", url.toString()); }, [handleMagicLink]); const verifying = phase === "verifying"; return ( <section className="magic-link"> {verifying && <p>Verifying your link...</p>} {phase === "authenticated" && <p>Signed in successfully. Redirecting...</p>} {phase === "error" && ( <div role="alert"> <p>{error ?? "Link verification failed."}</p> <button onClick={reset}>Start over</button> </div> )} </section> ); }

How this maps to the real scenario

Magic links previously caused duplicate submissions when users double‑clicked or refreshed. The hook’s token cache stops concurrent verifies, while the handler removes query parameters to prevent accidental replays. Origin binding is preserved by passing auth_request_id when present and falling back to the stored value in the hook. The end result mirrors the team’s goal: instant verification on arrival, predictable states (verifying → authenticated | error), and a clean URL that won’t surprise support or users.

Providing application-wide authentication state and persistence

Global state solved our team’s duplicate logic. Instead of each screen inferring whether the user is signed in, a single context exposes phase, email, and actions from the hook. Your AuthProvider wraps the tree and offers useAuth() as the only interface. The hook performs session hydration on mount by calling /auth/passwordless/session; if a cookie session exists, it immediately promotes the app to authenticated. Local storage persists authRequestId, the last sendResult, and a session email, so reloads and route changes don’t reset the flow.

Auth context plus gate flow

AuthProvider centralizes state, so routes only need to check one flag: authenticated or not.

Route protection with declarative, testable gates

// client/src/auth/AuthGate.tsx import { ReactNode } from "react"; import { useAuth } from "./AuthProvider"; export function AuthGate({ children, fallback }: { children: ReactNode; fallback: ReactNode }) { const { phase } = useAuth(); if (phase !== "authenticated") return <>{fallback}; return <>{children}</>; } // Usage in routes (example) <AuthGate fallback={<LoginPage />}> <Dashboard /> </AuthGate>

Why this fixes the scenario’s pain points

The team’s earlier approach mixed ad‑hoc checks and duplicated fetches across screens, producing races after redirects. Centralizing the state eliminates those races: only the hook talks to the backend, only the provider publishes state, and only gates decide what to render. Persistence prevents users from getting stuck between email send and verification, and the session bootstrap upgrades returning users without flashing the login. The result is a consistent, portable authentication surface that scales with new routes and layouts.

Handling loading states and error boundaries for predictable UX

The team’s old flow caused confusion for users whenever issues arose, spinners froze, error messages disappeared upon refresh, and the app occasionally crashed mid-verify. Two patterns fix this: explicit loading states tied to the hook’s phase, and a global error boundary that catches unexpected runtime errors. Together, they ensure that authentication UI never leaves the user without feedback.

Explicit loading and error surfaces

Each UI component maps phases into concrete messages or spinners. For example:

  • phase === "sending" → disable form and show “Sending link…”
  • phase === "verifying" → show progress bar while awaiting backend
  • phase === "error" → render error string, with a restart option

This approach guarantees every async branch has a visible outcome.

Global error capture with boundaries

// client/src/components/AppErrorBoundary.tsx import { Component, ReactNode } from "react"; export class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { constructor(props: any) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: unknown, info: unknown) { console.error("App error", error, info); } render() { if (this.state.hasError) { return <div role="alert">Something went wrong. Please refresh or try again later.</div>; } return this.props.children; } }

Wrapping the entire app in AppErrorBoundary ensures that if a logic bug slips through, say, a malformed JSON response, the app shows a recoverable error instead of a blank screen.

Back to the scenario

Where the team once saw blank states or unresponsive forms, users now always see a spinner, a countdown, or a retry option. Even unhandled errors degrade gracefully, protecting the authentication experience and avoiding the support churn that plagued their first attempt.

Enforcing type safety with TypeScript across all authentication flows

In the team’s first attempt, missing checks for edge states caused silent failures, expired codes weren’t cleared, and UI components incorrectly assumed the user was authenticated. By encoding states and API responses in TypeScript, those gaps become compiler errors instead of runtime surprises.

Defining explicit auth phases and API shapes

// client/src/types/auth.ts export type PasswordlessPhase = | "idle" | "sending" | "codeSent" | "verifying" | "authenticated" | "error"; export interface SendResult { authRequestId: string; expiresAt: number; // unix timestamp (seconds) method: "otp" | "magic_link"; }

Every component consumes phase: PasswordlessPhase, which forces a switch or conditional to cover all possibilities. When the hook adds a new phase, the compiler prompts updates in every consumer.

Typing hook return values for context safety

// usePasswordlessAuth returns an object typed here export interface PasswordlessAuth { phase: PasswordlessPhase; error: string | null; email: string; sendResult: SendResult | null; timeLeft: number; send(email: string): Promise<void>; verifyCode(code: string): Promise<void>; handleMagicLink(token: string, authRequestId?: string): Promise<void>; logout(): Promise<void>; reset(): void; }

The AuthProvider context then exposes PasswordlessAuth, ensuring every component that calls useAuth() gets full intellisense and can’t mis-type function calls.

How this solved the scenario’s pain points

The team’s early React prototype failed silently when the backend changed a response shape. With explicit SendResult and PasswordlessPhase, those errors now surface as compile-time breaks. Developers can add flows confidently, knowing unhandled cases will be flagged by the compiler instead of reaching production.

Testing patterns and mocking strategies for reliable passwordless flows

Automated tests prove that the flow survives redirects, expired codes, and double‑clicks, the exact failure modes from our scenario. Unit tests target the hook’s state machine; component tests cover input behavior, countdowns, and URL handling. Vitest runs the suite with a JSDOM environment, while Testing Library simulates user interaction. Network calls are mocked by stubbing global.fetch; timers are controlled with vi.useFakeTimers() to advance expiries deterministically. Local storage is shimmed so hydration and cleanup logic can be asserted without a browser.

What to mock and why

  • fetch → return shaped JSON for /send, /verify-code, /verify-link, /session, /logout.
  • Date.now() or timers → advance expiresAt countdowns and trigger auto‑clear paths.
  • localStorage → seed pl.sendResult and pw_auth_request_id, then assert cleanup.
  • window.location and history.replaceState → emulate magic link redirects and cleanup.
// test/utils/mockFetch.ts export function mockFetch(routes: Record<string, (body: any) => any>) { vi.spyOn(global, "fetch").mockImplementation(async (url: any, init?: any) => { const u = String(url); const body = init?.body ? JSON.parse(init.body as string) : null; const handler = Object.entries(routes).find(([path]) => u.endsWith(path))?.[1]; if (!handler) return new Response(JSON.stringify({ error: "NOT_FOUND" }), { status: 404 }); const payload = handler(body); const status = payload?.status ?? 200; return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json" } }); }); }

Hook tests validate phases and edge cases

// test/usePasswordlessAuth.test.tsx import { renderHook, act } from "@testing-library/react"; import { usePasswordlessAuth } from "../client/src/auth/usePasswordlessAuth"; import { mockFetch } from "./utils/mockFetch"; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); localStorage.clear(); }); it("sends and hydrates OTP flow, then verifies", async () => { mockFetch({ "/auth/passwordless/send": () => ({ authRequestId: "req1", expiresAt: 1735689660, method: "otp" }), "/auth/passwordless/verify-code": () => ({ email: "a@b.com" }), "/auth/passwordless/session": () => ({ authenticated: false }), }); const { result } = renderHook(() => usePasswordlessAuth({ apiBase: "/api" })); await act(async () => { await result.current.send("a@b.com"); }); expect(result.current.phase).toBe("codeSent"); expect(result.current.sendResult?.authRequestId).toBe("req1"); await act(async () => { await result.current.verifyCode("123456"); }); expect(result.current.phase).toBe("authenticated"); });

Component tests simulate user paths from the story

  • EmailForm → type invalid then valid email, assert disabled/enabled submit, assert sending lock.
  • OtpForm → paste six digits, auto‑submit, assert verifying → authenticated; advance timers to force expiry and assert reset UI.
  • MagicLinkHandler → set ?link_token=... in window.location, assert verifying, then success; verify query params are removed via history.replaceState.
  • Session bootstrap → mock /session returning { authenticated: true, email }, render provider only, and assert immediate authenticated without flicker.

These tests prevent regressions where the team once struggled: duplicate requests, racey countdowns, and brittle redirects. The suite locks in the desired behavior, so refactors to the hook or UI can proceed with confidence.

These tests replay the exact failure modes from our scenario, including double-click resends, racey countdown expiries, and magic-link refreshes, so the suite proves the refactor eliminates the issues that originally made the flow brittle.

Bringing it all together: production-ready passwordless authentication in React

What started as a messy React prototype, full of duplicated state, brittle redirects, and endless password resets, has been reshaped into a production-ready flow that is both secure and reusable. By structuring the frontend around a usePasswordlessAuth hook and global AuthProvider, and letting Scalekit enforce token issuance and verification on the backend, the SaaS team in our story went from firefighting login bugs to shipping a flow they can actually trust.

The pieces now work together cleanly:

  • Scalekit + Express API: issues and validates short-lived credentials with expiry and origin guarantees.
  • React 18 + TypeScript: encapsulates client-side complexity in predictable hooks and contexts.
  • Reusable components: EmailForm, OtpForm, and MagicLinkHandler declaratively render progress, errors, and edge states without ad-hoc logic.
  • Resilient UX and testing: error boundaries, typed phases, and Vitest suites lock in behavior and prevent regressions.

The result maps directly back to the original pain points: magic links validate once, OTPs expire predictably, sessions persist across reloads, and users never see a frozen screen. What was once fragile boilerplate is now a portable authentication layer that can be dropped into future React projects without repeating old mistakes.

If you’re building modern authentication flows, the next steps are clear:

  • Clone the full working project from GitHub to see the implementation in action.
  • Read deeper into related topics, server-side session hardening, cross-origin replay protection, or advanced testing patterns for auth.
  • Try Scalekit in your own project to offload token security and focus on building great user experiences.

Authentication doesn’t have to be the most painful part of your stack. With React hooks for state and Scalekit for security, passwordless login becomes not just possible, but practical and production-ready.

FAQ

How does Scalekit enforce magic link origin binding in passwordless authentication?

Scalekit pairs each issued token with an authRequestId that is bound to the originating browser session. When the React app calls /verify-link, the SDK checks that the provided token matches both the request and its original client context, preventing token replay across devices.

Can Scalekit passwordless authentication handle multiple resend requests safely?

Yes. Scalekit enforces both credential freshness and rate limiting. When a new OTP or magic link is issued, the previous authRequestId is invalidated, so only the most recent credential can succeed. On top of that, the /send and /resend endpoints are capped at 2 passwordless emails per minute per address, and the /verify endpoint allows only five OTP attempts within ten minutes. These safeguards prevent credential spamming and brute-force guessing while keeping the flow reliable for legitimate users.

Why use a custom React hook for passwordless authentication instead of Redux or Zustand?

A hook tightly couples state transitions (idle → sending → codeSent → verifying → authenticated) with side-effects like API calls and localStorage hydration. This keeps authentication logic isolated, prevents global store bloat, and lets components stay declarative by consuming only what they need.

How should OTP expiry and countdown be implemented in React without memory leaks?

Use useEffect with a setInterval tied to the OTP’s expiresAt value. On unmount or expiry, clear the interval. This approach ensures countdown timers don’t accumulate across re-renders and guarantees React’s cleanup cycle prevents memory leaks.

What are the best practices for testing passwordless flows in React with Vitest?

Mock fetch responses for /send, /verify-code, /verify-link, and /session endpoints. Control time with vi.useFakeTimers() to simulate code expiry. Simulate paste-to-fill in OTP forms using Testing Library events. These patterns validate the full state machine without needing a live backend.

No items found.
On this page
Share this article
Modernize login flows

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 FREE SSO/SCIM connection each
1000 Monthly active users
25 Monthly active organizations
Passwordless auth
API auth: 1000 M2M tokens
MCP auth: 1000 M2M tokens