TechLead

Authentication & Auth Patterns

Protected routes, auth context, JWT/session handling, and login flows in React

Authentication in React

Authentication is a critical part of most web applications. React handles the frontend side β€” login/signup forms, storing tokens, protecting routes, and managing user sessions. The actual authentication logic (verifying credentials, issuing tokens) happens on the backend.

πŸ” Auth Flow Overview

  1. User enters credentials in a login form
  2. Frontend sends credentials to the backend API
  3. Backend verifies and returns a token (JWT) or sets a session cookie
  4. Frontend stores the token and includes it in subsequent API requests
  5. Protected routes check for valid authentication before rendering

Building an Auth Context

Create a context that provides auth state throughout your app:

// context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // Check for existing session on mount
  useEffect(() => {
    checkAuth();
  }, []);

  async function checkAuth() {
    try {
      const token = localStorage.getItem('token');
      if (!token) {
        setLoading(false);
        return;
      }

      // Verify token with backend
      const res = await fetch('/api/auth/me', {
        headers: { Authorization: `Bearer ${token}` },
      });

      if (res.ok) {
        const userData = await res.json();
        setUser(userData);
      } else {
        localStorage.removeItem('token');
      }
    } catch (error) {
      console.error('Auth check failed:', error);
    } finally {
      setLoading(false);
    }
  }

  async function login(email, password) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) {
      const data = await res.json();
      throw new Error(data.message || 'Login failed');
    }

    const { user, token } = await res.json();
    localStorage.setItem('token', token);
    setUser(user);
    return user;
  }

  async function signup(email, password, name) {
    const res = await fetch('/api/auth/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password, name }),
    });

    if (!res.ok) {
      const data = await res.json();
      throw new Error(data.message || 'Signup failed');
    }

    const { user, token } = await res.json();
    localStorage.setItem('token', token);
    setUser(user);
    return user;
  }

  function logout() {
    localStorage.removeItem('token');
    setUser(null);
  }

  return (
    <AuthContext.Provider value={{ user, loading, login, signup, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook for easy access
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Login and Signup Forms

// components/LoginForm.jsx
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const { login } = useAuth();
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      await login(email, password);
      navigate('/dashboard');
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
      <h2 className="text-2xl font-bold">Log In</h2>

      {error && (
        <div className="p-3 bg-red-50 text-red-700 rounded">{error}</div>
      )}

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          required
          className="w-full p-2 border rounded"
        />
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          required
          className="w-full p-2 border rounded"
        />
      </div>

      <button
        type="submit"
        disabled={isLoading}
        className="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        {isLoading ? 'Logging in...' : 'Log In'}
      </button>
    </form>
  );
}

Protected Routes

Create a wrapper component that redirects unauthenticated users:

// components/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

export function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();
  const location = useLocation();

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
      </div>
    );
  }

  if (!user) {
    // Redirect to login, preserving the intended destination
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

// Role-based protection
export function AdminRoute({ children }) {
  const { user, loading } = useAuth();

  if (loading) return <LoadingSpinner />;
  if (!user) return <Navigate to="/login" replace />;
  if (user.role !== 'admin') return <Navigate to="/unauthorized" replace />;

  return children;
}

// Usage in router
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<LoginForm />} />
          <Route path="/signup" element={<SignupForm />} />

          {/* Protected routes */}
          <Route path="/dashboard" element={
            <ProtectedRoute><Dashboard /></ProtectedRoute>
          } />
          <Route path="/profile" element={
            <ProtectedRoute><Profile /></ProtectedRoute>
          } />
          <Route path="/admin" element={
            <AdminRoute><AdminPanel /></AdminRoute>
          } />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

Authenticated API Requests

Create a wrapper around fetch that automatically includes the auth token:

// lib/api.js
export async function authFetch(url, options = {}) {
  const token = localStorage.getItem('token');

  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...options.headers,
    },
  });

  // Handle token expiration
  if (res.status === 401) {
    localStorage.removeItem('token');
    window.location.href = '/login';
    throw new Error('Session expired');
  }

  return res;
}

// Custom hook for authenticated data fetching
function useAuthQuery(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    authFetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

// Usage
function Dashboard() {
  const { data: stats, loading } = useAuthQuery('/api/dashboard/stats');

  if (loading) return <Skeleton />;
  return <StatsGrid data={stats} />;
}

Showing Auth-Dependent UI

// components/Navbar.jsx
import { useAuth } from '../context/AuthContext';
import { Link } from 'react-router-dom';

function Navbar() {
  const { user, logout } = useAuth();

  return (
    <nav className="flex items-center justify-between p-4 border-b">
      <Link to="/" className="font-bold text-xl">MyApp</Link>

      <div className="flex items-center gap-4">
        {user ? (
          <>
            <span>Hello, {user.name}</span>
            <Link to="/dashboard">Dashboard</Link>
            {user.role === 'admin' && <Link to="/admin">Admin</Link>}
            <button
              onClick={logout}
              className="px-3 py-1 bg-red-500 text-white rounded"
            >
              Log Out
            </button>
          </>
        ) : (
          <>
            <Link to="/login">Log In</Link>
            <Link to="/signup" className="px-3 py-1 bg-blue-500 text-white rounded">
              Sign Up
            </Link>
          </>
        )}
      </div>
    </nav>
  );
}

πŸ”’ Security Best Practices

  • β€’ Never store sensitive data in localStorage in production β€” use httpOnly cookies for tokens when possible
  • β€’ Always validate on the server β€” frontend auth is for UX, not security
  • β€’ Use HTTPS for all API requests
  • β€’ Implement token refresh β€” don't use long-lived access tokens
  • β€’ Clear auth state on logout (tokens, cached data, cookies)
  • β€’ Protect against XSS β€” sanitize inputs, use Content Security Policy
  • β€’ Handle token expiration gracefully β€” redirect to login, don't crash

βœ… Production Auth Libraries

For production apps, consider using established auth solutions:

  • β€’ NextAuth.js / Auth.js β€” Full-featured auth for Next.js (OAuth, credentials, sessions)
  • β€’ Firebase Auth β€” Google's auth service with social providers
  • β€’ Supabase Auth β€” Open-source auth with row-level security
  • β€’ Clerk β€” Drop-in auth components with user management
  • β€’ Auth0 β€” Enterprise auth with extensive customization