Welcome, aspiring React architect! In the journey of building robust, production-ready applications, few topics are as critical and often misunderstood as authentication and authorization. This chapter is your deep dive into securing your React applications, ensuring that only the right users can access the right resources and features.

We’ll explore the fundamental differences between authentication and authorization, delve into modern token-based security patterns, and implement secure user flows right within your React app. By the end of this chapter, you’ll not only understand how to implement these features but also why each piece is crucial for maintaining a secure and reliable system. We’ll build upon our knowledge of data fetching from previous chapters, integrating security seamlessly into our API interactions.

Ready to make your React apps fortress-like? Let’s begin!

8.1 Authentication vs. Authorization: A Clear Distinction

Before we write any code, let’s solidify the foundational concepts. While often used interchangeably, authentication and authorization are distinct processes that work hand-in-hand to secure your application.

8.1.1 What is Authentication?

Authentication is the process of verifying who a user is. Think of it like showing your ID at the entrance of a building. The system checks your credentials (e.g., username and password) against a database to confirm your identity.

  • Why it matters: Without authentication, your application wouldn’t know if the person trying to access data is a legitimate user or an intruder. It’s the first line of defense.
  • Failures if ignored: Anyone could claim to be anyone, leading to unauthorized access to user accounts and sensitive data.

8.1.2 What is Authorization?

Authorization is the process of determining what an authenticated user is allowed to do or access. Once you’re inside the building (authenticated), authorization dictates which rooms you can enter or which resources you can use. Are you an administrator, a regular user, or a guest? Each role comes with different permissions.

  • Why it matters: Even if a user is legitimate, they shouldn’t necessarily have access to everything. Authorization enforces business rules and prevents users from performing actions they aren’t privileged to do (e.g., a regular user shouldn’t be able to delete another user’s account).
  • Failures if ignored: Authenticated users could bypass intended restrictions, potentially modifying or deleting critical data, or accessing sensitive information meant for a different role.

8.2 Token-Based Authentication: The Modern Approach

In modern web applications, especially Single Page Applications (SPAs) like those built with React, token-based authentication is the de facto standard. Instead of traditional session cookies (though they still have their place, especially HttpOnly cookies for refresh tokens), we often rely on JSON Web Tokens (JWTs).

8.2.1 JSON Web Tokens (JWTs)

A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using a secret (or a public/private key pair).

  • What it is: A string with three parts, separated by dots: header.payload.signature.
    • Header: Describes the token type (JWT) and the signing algorithm (e.g., HMAC SHA256).
    • Payload: Contains the “claims” – statements about an entity (typically the user) and additional data. Common claims include iss (issuer), exp (expiration time), sub (subject), and custom data like userId or roles.
    • Signature: Created by taking the encoded header, encoded payload, a secret key, and the algorithm specified in the header, then signing it. This ensures the token hasn’t been tampered with.
  • Why it’s important: JWTs are stateless (the server doesn’t need to store session information), scalable, and can carry user-specific data (claims) securely.
  • How it functions: After a user logs in, the server generates a JWT (typically an “Access Token”) and sends it back to the client. The client then includes this token in the Authorization header of subsequent API requests. The server validates the token’s signature and expiration, then extracts the claims to identify the user and their permissions.

8.2.2 Access Tokens vs. Refresh Tokens

For enhanced security and user experience, production applications often employ a pair of tokens: an Access Token and a Refresh Token.

  • Access Token:
    • Purpose: Used to authenticate API requests for protected resources.
    • Lifespan: Short-lived (e.g., 5-15 minutes).
    • Security: If intercepted, its short lifespan limits exposure.
    • Storage (Frontend): Typically stored in memory or localStorage (with careful consideration of XSS risks, discussed next).
  • Refresh Token:
    • Purpose: Used to obtain a new Access Token once the current one expires, without requiring the user to log in again.
    • Lifespan: Long-lived (e.g., days, weeks, or months).
    • Security: Highly sensitive. Must be protected diligently.
    • Storage (Frontend): Crucially, stored in an HttpOnly cookie. This prevents JavaScript (and thus XSS attacks) from accessing it.

8.2.3 Token Storage Trade-offs

Where you store tokens on the client-side is a critical security decision.

  • localStorage / sessionStorage:
    • Pros: Easy to use, accessible via JavaScript. Persists across browser sessions (localStorage).
    • Cons: Vulnerable to Cross-Site Scripting (XSS) attacks. If an attacker injects malicious JavaScript into your page, they can easily read tokens from localStorage and use them to impersonate the user. This is a significant risk for Access Tokens, and a catastrophic risk for Refresh Tokens.
    • Best Practice: Avoid for Refresh Tokens entirely. For Access Tokens, if used, assume it’s an acceptable risk only if your XSS defenses are watertight, and the token is very short-lived. Often, in-memory storage is preferred for Access Tokens.
  • In-memory (e.g., in a React state variable):
    • Pros: Immune to XSS attacks (as it’s not persisted anywhere accessible by script injection). Cleared on page refresh.
    • Cons: User has to re-authenticate on every page refresh or tab close. Requires a Refresh Token mechanism to maintain user sessions.
    • Best Practice: Ideal for Access Tokens when paired with HttpOnly cookies for Refresh Tokens.
  • HttpOnly Cookies:
    • Pros: Immune to XSS attacks because JavaScript cannot access them. Immune to Cross-Site Request Forgery (CSRF) attacks if properly configured with SameSite=Lax or Strict and anti-CSRF tokens. Automatically sent with every request to the server.
    • Cons: Requires server-side management to set the cookie. Cannot be read by client-side JavaScript, making it harder to debug or implement certain client-side logic based on the token itself (though the server can send back necessary user data).
    • Best Practice: Highly recommended for Refresh Tokens. The server sets this cookie after successful login.

8.2.4 Refresh Flows

When an Access Token expires, the client needs a new one. This is where the Refresh Token comes in.

sequenceDiagram participant Client participant API Gateway participant Auth Server Client->>API Gateway: Request with Expired Access Token API Gateway->>Auth Server: Validate Access Token Auth Server-->>API Gateway: Access Token Expired (401 Unauthorized) API Gateway-->>Client: 401 Unauthorized Response Client->>Auth Server: Request New Access Token with Refresh Token (from HttpOnly cookie) Auth Server->>Auth Server: Validate Refresh Token Auth Server-->>Client: New Access Token + New Refresh Token (via HttpOnly cookie) Client->>API Gateway: Retry Original Request with New Access Token API Gateway->>Auth Server: Validate New Access Token Auth Server-->>API Gateway: Access Token Valid API Gateway-->>Client: Original Resource Data
  • Why it exists: To provide a seamless user experience (no re-login) while keeping Access Tokens short-lived for security.
  • How it functions:
    1. Client makes an API request with an Access Token.
    2. Server rejects the request with a 401 Unauthorized status if the Access Token is expired or invalid.
    3. Client-side code (often an HTTP interceptor) catches the 401.
    4. The client then makes a special request to a /refresh-token endpoint, sending the Refresh Token (which is automatically included in the HttpOnly cookie).
    5. The server validates the Refresh Token, issues a new Access Token and potentially a new Refresh Token (for rotation), and sends them back.
    6. The client updates its Access Token and then retries the original failed request.

8.3 Step-by-Step Implementation: Building a Secure Auth Flow

Let’s put these concepts into practice. We’ll build a basic authentication system using React Context for global state, Axios for API calls, and implement protected routes.

We’ll assume you have a basic React project set up (e.g., created with Vite or Create React App). For this example, we’ll use axios for network requests.

First, install axios:

npm install axios@^1.6.0 # As of 2026-02-11, Axios 1.x is stable and widely used.

8.3.1 Setting Up the Auth Context

We’ll use React’s Context API to manage our authentication state globally. This allows any component to easily check if a user is logged in, access user information, or trigger login/logout actions.

Create a new file src/contexts/AuthContext.tsx:

// src/contexts/AuthContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  ReactNode,
} from 'react';
import axios from 'axios'; // We'll use axios for our API calls

// --- 1. Define the shape of our authentication state and actions ---
interface User {
  id: string;
  username: string;
  roles: string[];
  // Add any other user details you need
}

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  login: (accessToken: string, refreshToken?: string) => Promise<void>;
  logout: () => Promise<void>;
  loading: boolean;
  // This function will be used by the Axios interceptor
  getAccessToken: () => string | null;
  setAccessToken: (token: string | null) => void;
}

// --- 2. Create the Auth Context ---
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// --- 3. Create a custom hook for easy access to the context ---
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

// --- 4. Create the Auth Provider Component ---
interface AuthProviderProps {
  children: ReactNode;
}

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'; // Example API URL
const TOKEN_STORAGE_KEY = 'accessToken'; // Key for storing access token in localStorage (for simplicity)

export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [loading, setLoading] = useState(true); // To indicate initial auth check is in progress
  let currentAccessToken: string | null = null; // In-memory store for the access token

  // Utility to update the in-memory access token and localStorage
  const setAccessToken = useCallback((token: string | null) => {
    currentAccessToken = token;
    if (token) {
      localStorage.setItem(TOKEN_STORAGE_KEY, token);
    } else {
      localStorage.removeItem(TOKEN_STORAGE_KEY);
    }
  }, []);

  const getAccessToken = useCallback(() => {
    if (!currentAccessToken) {
      currentAccessToken = localStorage.getItem(TOKEN_STORAGE_KEY);
    }
    return currentAccessToken;
  }, []);

  // --- 5. Function to decode JWT and set user data ---
  const decodeAndSetUser = useCallback((token: string) => {
    try {
      // In a real app, you'd verify the token on the server or use a library
      // For demonstration, we'll just decode the payload
      const payload = JSON.parse(atob(token.split('.')[1]));
      setUser({
        id: payload.userId,
        username: payload.username,
        roles: payload.roles || [], // Assuming roles are in the payload
      });
      setIsAuthenticated(true);
    } catch (error) {
      console.error('Failed to decode access token:', error);
      setUser(null);
      setIsAuthenticated(false);
      setAccessToken(null); // Clear invalid token
    }
  }, [setAccessToken]);

  // --- 6. Login function ---
  const login = useCallback(async (accessToken: string, refreshToken?: string) => {
    setAccessToken(accessToken);
    // In a real app, the backend would set the HttpOnly refreshToken cookie here
    // For this client-side example, we just acknowledge the token.
    decodeAndSetUser(accessToken);
    // Optionally, if the backend sends user data directly, you'd use that
  }, [setAccessToken, decodeAndSetUser]);

  // --- 7. Logout function ---
  const logout = useCallback(async () => {
    // In a real app, you'd likely hit a logout API endpoint to invalidate tokens server-side
    // For now, we clear client-side state
    setUser(null);
    setIsAuthenticated(false);
    setAccessToken(null);
    // Backend would clear HttpOnly refreshToken cookie
  }, [setAccessToken]);

  // --- 8. Initial check on component mount for existing token ---
  useEffect(() => {
    const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY);
    if (storedToken) {
      // In a real app, you'd often send this token to a backend /verify endpoint
      // to ensure it's still valid and not revoked.
      // For simplicity, we'll just decode it client-side.
      decodeAndSetUser(storedToken);
      setAccessToken(storedToken);
    }
    setLoading(false); // Finished initial auth check
  }, [decodeAndSetUser, setAccessToken]);

  // --- 9. Provide the context values to children ---
  const authContextValue: AuthContextType = {
    user,
    isAuthenticated,
    login,
    logout,
    loading,
    getAccessToken,
    setAccessToken,
  };

  return <AuthContext.Provider value={authContextValue}>{children}</AuthContext.Provider>;
};

Explanation:

  1. AuthContextType and User interfaces: Define the structure of our authentication state (user, isAuthenticated, loading) and the functions to interact with it (login, logout). We also include getAccessToken and setAccessToken for our Axios interceptor.
  2. createContext: Initializes the context.
  3. useAuth hook: A convenient custom hook to consume the AuthContext, ensuring it’s used within an AuthProvider.
  4. AuthProvider component:
    • Manages the actual authentication state using useState.
    • loading state helps us know when the initial authentication check is complete, preventing UI flashes.
    • currentAccessToken is an in-memory variable to hold the token, making it immediately available without always hitting localStorage.
    • setAccessToken and getAccessToken are crucial for managing the token. We’re using localStorage for the Access Token for simplicity, but remember the XSS risks. In a more secure setup, currentAccessToken would be purely in-memory, and login/refresh would supply it directly.
  5. decodeAndSetUser: A utility to parse the JWT payload (for demonstration purposes) and populate the user state. In a real-world scenario, you’d often have a /me or /profile endpoint to fetch detailed user data after login with a valid token, rather than relying solely on JWT payload for all user info.
  6. login function: Takes an accessToken and (optionally) a refreshToken. It stores the accessToken and updates the user state.
  7. logout function: Clears the user state and removes the token from storage.
  8. useEffect for initial check: When the AuthProvider mounts, it checks localStorage for an existing accessToken. If found, it attempts to set the user as authenticated. This handles page refreshes.
  9. Context Provider: Renders children components, making the authentication state and functions available to them.

8.3.2 Integrating Axios with Token Interceptors

To automatically attach the Access Token to outgoing requests and handle token refreshes, we’ll configure Axios interceptors.

Create a new file src/utils/axiosInstance.ts:

// src/utils/axiosInstance.ts
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { AuthContextType } from '../contexts/AuthContext'; // Import the type for useAuth

// This instance will be configured with interceptors
const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
  withCredentials: true, // Important for sending HttpOnly cookies (refresh token)
});

// A variable to store our AuthContext functions
let authService: Pick<AuthContextType, 'getAccessToken' | 'setAccessToken' | 'logout'>;

// Function to inject AuthContext functions after the AuthProvider is mounted
export const injectAuthService = (service: Pick<AuthContextType, 'getAccessToken' | 'setAccessToken' | 'logout'>) => {
  authService = service;
};

// --- Request Interceptor: Attach Access Token ---
axiosInstance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    const accessToken = authService?.getAccessToken();
    if (accessToken && config.headers) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error: AxiosError) => {
    return Promise.reject(error);
  }
);

// --- Response Interceptor: Handle Token Refresh ---
let isRefreshing = false;
let failedQueue: { resolve: (value?: unknown) => void; reject: (reason?: any) => void; config: AxiosRequestConfig }[] = [];

const processQueue = (error: AxiosError | null, token: string | null = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(axiosInstance(prom.config));
    }
  });
  failedQueue = [];
};

axiosInstance.interceptors.response.use(
  (response: AxiosResponse) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };

    // Check if the error is 401 Unauthorized and it's not a retry request
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // Mark as retried

      if (isRefreshing) {
        // If a token refresh is already in progress, queue the request
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject, config: originalRequest });
        });
      }

      isRefreshing = true;

      try {
        // Call the refresh token endpoint.
        // This endpoint expects the HttpOnly refresh token cookie to be sent automatically.
        const response = await axiosInstance.post('/auth/refresh-token'); // Assuming /auth/refresh-token endpoint
        const { accessToken } = response.data;

        authService.setAccessToken(accessToken); // Update client-side access token
        processQueue(null, accessToken); // Process all queued requests with the new token

        // Retry the original request with the new access token
        originalRequest.headers = {
          ...originalRequest.headers,
          Authorization: `Bearer ${accessToken}`,
        };
        return axiosInstance(originalRequest);
      } catch (refreshError: any) {
        processQueue(refreshError, null); // Reject all queued requests
        authService.logout(); // Log out the user if refresh fails
        // Redirect to login page or show an error
        console.error('Failed to refresh token, logging out:', refreshError);
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;

Explanation:

  1. axiosInstance: A custom Axios instance with a base URL and withCredentials: true (essential for sending HttpOnly cookies for refresh tokens).
  2. injectAuthService: A utility function to provide the Axios interceptors access to our AuthContext’s getAccessToken, setAccessToken, and logout functions. This helps avoid circular dependencies.
  3. Request Interceptor:
    • Runs before each request.
    • Retrieves the current accessToken using authService.getAccessToken().
    • If a token exists, it adds it to the Authorization header as a Bearer token.
  4. Response Interceptor:
    • Runs after each response.
    • Checks for a 401 Unauthorized status.
    • Token Refresh Logic:
      • isRefreshing and failedQueue: These variables prevent a “thundering herd” problem where multiple concurrent 401 responses all try to refresh the token simultaneously. If a refresh is already in progress, new 401 requests are queued.
      • If 401 occurs and no refresh is active:
        • Sets _retry flag on the original request to prevent infinite loops.
        • Makes a POST request to /auth/refresh-token. This endpoint relies on the HttpOnly refresh token cookie being sent automatically by the browser due to withCredentials: true.
        • If successful, it updates the accessToken via authService.setAccessToken() and retries all queued requests.
        • If refresh fails, it calls authService.logout() (which clears client-side tokens and should redirect the user to login).
      • Finally, it retries the original failed request with the newly obtained access token.

8.3.3 Wrapping Your App with AuthProvider

Now, wrap your root application component (App.tsx or main.tsx) with the AuthProvider and inject the auth service into Axios.

// src/main.tsx (or App.tsx)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { AuthProvider, useAuth } from './contexts/AuthContext.tsx';
import { injectAuthService } from './utils/axiosInstance.ts';

// Component to inject auth service into Axios after AuthProvider is ready
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const auth = useAuth();
  // We use useEffect to ensure auth context is fully initialized before injecting
  useEffect(() => {
    if (auth) {
      injectAuthService(auth);
    }
  }, [auth]); // Re-run if auth object changes (unlikely)
  return <>{children}</>;
};


ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <AuthProvider>
      <AuthInitializer>
        <App />
      </AuthInitializer>
    </AuthProvider>
  </React.StrictMode>,
);

Explanation:

  • We wrap the entire <App /> with <AuthProvider> so that all components can access the auth state.
  • AuthInitializer is a small helper component that uses useAuth inside the AuthProvider’s scope and then calls injectAuthService. This ensures that authService in axiosInstance.ts is properly populated with the functions from our AuthContext.

8.3.4 Building a Login Component

Let’s create a simple login form.

// src/components/Login.tsx
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import axiosInstance from '../utils/axiosInstance';
import { useNavigate } from 'react-router-dom'; // Assuming react-router-dom for navigation

const Login: React.FC = () => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const { login } = useAuth();
  const navigate = useNavigate();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null); // Clear previous errors

    try {
      const response = await axiosInstance.post('/auth/login', { username, password });
      const { accessToken, refreshToken } = response.data; // Server sends these

      await login(accessToken, refreshToken); // Update auth context
      navigate('/dashboard'); // Redirect to a protected page
    } catch (err: any) {
      console.error('Login failed:', err);
      setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
    }
  };

  return (
    <div className="login-container">
      <h2>Login</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
          />
        </div>
        <div>
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>
        {error && <p className="error-message">{error}</p>}
        <button type="submit">Log In</button>
      </form>
    </div>
  );
};

export default Login;

Explanation:

  • Uses useState for form inputs.
  • useAuth() hook provides the login function.
  • axiosInstance.post('/auth/login', ...) sends credentials to the backend.
  • On successful login, it calls login(accessToken, refreshToken) to update the global auth state and redirects the user.
  • Error handling displays messages to the user.

8.3.5 Implementing Protected Routes

Now, let’s create a component that guards routes, redirecting unauthenticated users. We’ll need react-router-dom for this.

npm install react-router-dom@^6.22.0 # As of 2026-02-11, v6 is stable and widely used.
// src/components/ProtectedRoute.tsx
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Navigate, Outlet } from 'react-router-dom';

interface ProtectedRouteProps {
  // We can add roles here later for finer control
}

const ProtectedRoute: React.FC<ProtectedRouteProps> = () => {
  const { isAuthenticated, loading } = useAuth();

  if (loading) {
    // Optionally render a spinner or loading indicator while checking auth status
    return <div>Loading authentication...</div>;
  }

  if (!isAuthenticated) {
    // Redirect unauthenticated users to the login page
    return <Navigate to="/login" replace />;
  }

  // If authenticated, render the child routes/components
  return <Outlet />;
};

export default ProtectedRoute;

Explanation:

  • useAuth() provides isAuthenticated and loading status.
  • While loading, we can show a placeholder.
  • If !isAuthenticated, it uses Navigate from react-router-dom to redirect to /login. replace ensures the login page doesn’t get added to the browser history.
  • If authenticated, Outlet renders the child routes defined in your router configuration.

8.3.6 Setting Up Your Router

Integrate ProtectedRoute into your application’s routing.

// src/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import Login from './components/Login';
import Dashboard from './components/Dashboard'; // Create this component
import ProtectedRoute from './components/ProtectedRoute';
import Home from './components/Home'; // Create this component
import AdminPanel from './components/AdminPanel'; // Create this for role-based example
import { useAuth } from './contexts/AuthContext';

// Simple placeholder components
const Home: React.FC = () => <h2>Welcome Home!</h2>;
const Dashboard: React.FC = () => <h2>Dashboard Content (Protected)</h2>;
const AdminPanel: React.FC = () => <h2>Admin Panel (Admin Only)</h2>;

const App: React.FC = () => {
  const { isAuthenticated, logout, user, loading } = useAuth();

  if (loading) {
    return <div>Initializing application...</div>;
  }

  return (
    <Router>
      <nav style={{ padding: '1rem', background: '#f0f0f0' }}>
        <Link to="/" style={{ margin: '0 10px' }}>Home</Link>
        {!isAuthenticated && <Link to="/login" style={{ margin: '0 10px' }}>Login</Link>}
        {isAuthenticated && <Link to="/dashboard" style={{ margin: '0 10px' }}>Dashboard</Link>}
        {isAuthenticated && user?.roles.includes('admin') && (
          <Link to="/admin" style={{ margin: '0 10px' }}>Admin</Link>
        )}
        {isAuthenticated && (
          <button onClick={logout} style={{ margin: '0 10px' }}>Logout</button>
        )}
        {isAuthenticated && user && <span style={{ marginLeft: '10px' }}>Logged in as: {user.username}</span>}
      </nav>

      <div style={{ padding: '1rem' }}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />

          {/* Protected Routes */}
          <Route element={<ProtectedRoute />}>
            <Route path="/dashboard" element={<Dashboard />} />
            {/* Example of a route only accessible to admins */}
            <Route path="/admin" element={user?.roles.includes('admin') ? <AdminPanel /> : <Navigate to="/dashboard" replace />} />
          </Route>

          <Route path="*" element={<h2>404 Not Found</h2>} />
        </Routes>
      </div>
    </Router>
  );
};

export default App;

Explanation:

  • The Routes are wrapped with BrowserRouter (aliased as Router).
  • The <Route element={<ProtectedRoute />}> acts as a parent for all routes that require authentication. Any nested <Route> components will only render if ProtectedRoute allows it.
  • Role-Based Route: The /admin route demonstrates a simple in-route check for the admin role. If the user doesn’t have the role, they are redirected. This is a basic authorization check at the route level.

8.3.7 Role-Based UI Rendering

Beyond protecting entire routes, you often need to show or hide specific UI elements based on a user’s role or permissions.

Let’s enhance our navigation and AdminPanel to demonstrate this.

// src/components/AuthGuard.tsx (New component for UI rendering)
import React, { ReactNode } from 'react';
import { useAuth } from '../contexts/AuthContext';

interface AuthGuardProps {
  children: ReactNode;
  requiredRoles?: string[];
  fallback?: ReactNode; // Optional fallback UI if not authorized
}

const AuthGuard: React.FC<AuthGuardProps> = ({ children, requiredRoles, fallback = null }) => {
  const { user, isAuthenticated, loading } = useAuth();

  if (loading) {
    return fallback; // Or a loading spinner if you prefer
  }

  if (!isAuthenticated) {
    return fallback;
  }

  if (requiredRoles && requiredRoles.length > 0) {
    const userRoles = user?.roles || [];
    const hasRequiredRole = requiredRoles.some(role => userRoles.includes(role));
    if (!hasRequiredRole) {
      return fallback;
    }
  }

  return <>{children}</>;
};

export default AuthGuard;

Now, modify App.tsx and Dashboard.tsx to use AuthGuard:

// src/App.tsx (Updated navigation)
// ... (imports remain the same)

// Simple placeholder components
const Home: React.FC = () => <h2>Welcome Home!</h2>;
const Dashboard: React.FC = () => <DashboardContent />; // Use a separate component
const AdminPanel: React.FC = () => <h2>Admin Panel (Admin Only)</h2>;

const App: React.FC = () => {
  const { isAuthenticated, logout, user, loading } = useAuth();

  if (loading) {
    return <div>Initializing application...</div>;
  }

  return (
    <Router>
      <nav style={{ padding: '1rem', background: '#f0f0f0' }}>
        <Link to="/" style={{ margin: '0 10px' }}>Home</Link>
        {!isAuthenticated && <Link to="/login" style={{ margin: '0 10px' }}>Login</Link>}
        {isAuthenticated && <Link to="/dashboard" style={{ margin: '0 10px' }}>Dashboard</Link>}

        {/* Use AuthGuard for role-based UI in navigation */}
        <AuthGuard requiredRoles={['admin']}>
          <Link to="/admin" style={{ margin: '0 10px' }}>Admin</Link>
        </AuthGuard>

        {isAuthenticated && (
          <button onClick={logout} style={{ margin: '0 10px' }}>Logout</button>
        )}
        {isAuthenticated && user && <span style={{ marginLeft: '10px' }}>Logged in as: {user.username} (Roles: {user.roles.join(', ')})</span>}
      </nav>

      <div style={{ padding: '1rem' }}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />

          {/* Protected Routes */}
          <Route element={<ProtectedRoute />}>
            <Route path="/dashboard" element={<Dashboard />} />
            {/* Protect the Admin route with AuthGuard as well, though ProtectedRoute already handles auth */}
            <Route path="/admin" element={<AuthGuard requiredRoles={['admin']} fallback={<p>Access Denied!</p>}><AdminPanel /></AuthGuard>} />
          </Route>

          <Route path="*" element={<h2>404 Not Found</h2>} />
        </Routes>
      </div>
    </Router>
  );
};

export default App;

// src/components/Dashboard.tsx (Example usage of AuthGuard)
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import AuthGuard from './AuthGuard'; // Import AuthGuard

const DashboardContent: React.FC = () => {
  const { user } = useAuth();

  return (
    <div>
      <h2>Welcome to your Dashboard, {user?.username}!</h2>
      <p>Here's some general content.</p>

      <AuthGuard requiredRoles={['admin']} fallback={<p>You need admin privileges to see this.</p>}>
        <h3>Admin Section</h3>
        <p>This content is only visible to users with the 'admin' role.</p>
        <button>Manage Users</button>
      </AuthGuard>

      <AuthGuard requiredRoles={['editor']} fallback={null}>
        <h3>Editor Tools</h3>
        <p>You have editing capabilities.</p>
        <button>Edit Article</button>
      </AuthGuard>
    </div>
  );
};

export default DashboardContent;

Explanation:

  • AuthGuard.tsx: This component takes children, requiredRoles, and an optional fallback UI. It checks isAuthenticated and then iterates through requiredRoles to see if the user has any of them. If not, it renders fallback or null.
  • App.tsx (Navigation): The “Admin” link in the navigation bar is now conditionally rendered using AuthGuard.
  • App.tsx (Route Protection): The /admin route itself is wrapped with AuthGuard to ensure that even if someone directly navigates there, they are authorized by role. The ProtectedRoute ensures they are authenticated.
  • Dashboard.tsx: Demonstrates how to use AuthGuard within a component to conditionally render parts of the UI. This is excellent for feature flags based on roles.

Mini-Challenge: User Profile Editor

Challenge: Create a “Profile” page (/profile) that is only accessible to authenticated users. On this page, display the user’s username and roles. Add an “Edit Profile” button that is only visible if the user has an “editor” role.

Hint:

  1. Create a new React component Profile.tsx.
  2. Add a new Route for /profile inside your ProtectedRoute block in App.tsx.
  3. Use the useAuth hook in Profile.tsx to get user details.
  4. Use the AuthGuard component to conditionally render the “Edit Profile” button.

What to observe/learn: How to combine ProtectedRoute for route-level authentication and AuthGuard for in-component, role-based authorization to create a nuanced user experience.

8.4 Common Pitfalls & Troubleshooting

Authentication and authorization are complex. Here are common issues and how to approach them:

  1. Token Storage Mistakes:

    • Pitfall: Storing refresh tokens in localStorage.
    • Why it’s bad: Makes your application highly vulnerable to XSS attacks. If an attacker injects malicious script, they can steal the long-lived refresh token and maintain unauthorized access indefinitely.
    • Troubleshooting: Always use HttpOnly cookies for refresh tokens. If you must store an access token client-side, consider in-memory storage (cleared on refresh) or a very short-lived localStorage token with robust XSS defenses.
    • Debugging: Check browser DevTools -> Application -> Local Storage/Session Storage. If you see your refresh token there, it’s a problem. Also, check for HttpOnly cookies in DevTools -> Application -> Cookies.
  2. Race Conditions in Token Refresh:

    • Pitfall: Multiple API requests simultaneously receive 401 Unauthorized and all try to refresh the token, leading to multiple refresh token calls, potentially invalidating previous refresh tokens.
    • Why it’s bad: Can cause users to be logged out unexpectedly, or lead to 401 errors even after a refresh.
    • Troubleshooting: Implement the isRefreshing flag and failedQueue mechanism in your Axios interceptor, as demonstrated in axiosInstance.ts. This ensures only one refresh request is made at a time, and subsequent failing requests wait for the new token before retrying.
    • Debugging: In the Network tab, observe multiple /auth/refresh-token calls when many protected API calls fail simultaneously. If the interceptor works, you should only see one, followed by retries of the original failed requests.
  3. Incomplete Logout:

    • Pitfall: Clearing client-side tokens but not invalidating tokens on the server.
    • Why it’s bad: An attacker who has stolen a token (e.g., a refresh token from a compromised localStorage if not using HttpOnly) could still use it even after the legitimate user logs out.
    • Troubleshooting: Always have a /auth/logout endpoint on your backend that invalidates the refresh token (and potentially the access token) server-side. Your logout function in AuthContext should call this endpoint.
    • Debugging: After logging out, try to use a previously obtained token (e.g., via Postman). It should be rejected by the server.
  4. Misconfigured withCredentials or SameSite Cookies:

    • Pitfall: HttpOnly cookies aren’t sent with requests or are blocked by browser security.
    • Why it’s bad: Refresh token flow fails, user gets logged out.
    • Troubleshooting: Ensure axios.create({ withCredentials: true }) is set. On the backend, ensure your Set-Cookie header for the refresh token includes HttpOnly, Secure (for HTTPS), and SameSite=Lax or Strict (to prevent CSRF). If your frontend and backend are on different domains, you’ll need CORS configured correctly on the backend.
    • Debugging: In browser DevTools -> Network tab, inspect requests to your backend (especially /auth/refresh-token). Check the “Cookies” tab within the request details to see if the refresh token cookie is being sent. If not, inspect the initial login response’s Set-Cookie header.

8.5 Summary

You’ve just navigated one of the most crucial aspects of building secure and robust React applications! Here’s a quick recap of our key takeaways:

  • Authentication verifies who a user is, while Authorization determines what an authenticated user can do.
  • JSON Web Tokens (JWTs) are the backbone of modern token-based authentication, providing stateless and scalable security.
  • Access Tokens are short-lived and used for API requests, while Refresh Tokens are long-lived and used to obtain new Access Tokens.
  • Secure Token Storage is paramount: use HttpOnly cookies for Refresh Tokens to mitigate XSS risks, and consider in-memory storage for Access Tokens.
  • Refresh Flows ensure a seamless user experience by automatically renewing expired Access Tokens without re-authentication.
  • Axios Interceptors are powerful for automatically attaching tokens to requests and handling 401 Unauthorized responses for token refreshing.
  • React Context API provides an excellent way to manage global authentication state within your React application.
  • Protected Routes (using ProtectedRoute component) prevent unauthorized access to entire sections of your application.
  • Role-Based UI Rendering (using AuthGuard component) allows you to dynamically show or hide UI elements based on a user’s permissions, enhancing user experience and enforcing authorization.
  • Common pitfalls include incorrect token storage, race conditions during token refresh, and incomplete logout procedures.

By thoroughly understanding and implementing these patterns, you’re well on your way to building truly production-ready React applications with confidence.

What’s Next?

In the next chapter, we’ll continue our journey into security by exploring Frontend Security: XSS Prevention, CSP, and Secure Storage. This will build directly on our understanding of token storage and help you defend your applications against common web vulnerabilities.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.