Updated about 2 hours ago | GitHub

React Authentication Flows

Overview

Whenever you log in to a website, you’re using authentication. This is a critical aspect of web applications, ensuring that users can securely access their data and that protected resources remain secure. However, most of us only know authentication as the username and password login form:

Username and password login form

However, how does authentication actually work? This guide covers implementing secure authentication patterns in React applications.

This guide covers:

  • User authentication flows (login/logout)
  • Protecting routes from unauthorized access
  • Managing authentication tokens
  • Handling user sessions
  • Implementing role-based access control (RBAC)

Authentication vs. Authorization

Often “Auth” is used interchangeably with “Authorization”, but they are actually quite different. Understanding the difference between authentication and authorization is crucial for implementing secure applications. Let’s explain this using a hotel key card system.

Diagram of Hotel Key Card System

1 - Authentication (Who are you?)

Authentication is like checking in at the hotel front desk:

  • You prove your identity (passport/ID)
  • The front desk verifies your reservation
  • They issue you a key card, since they have “authenticated” you are who you say you are, and you have a valid reservation.

In web applications:

// Authentication example
async function loginUser(credentials) {
  try {
    // Send credentials to verify identity. `credentials: 'include'` lets the
    // server set an HttpOnly refresh-token cookie on the response.
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(credentials)
    });

    if (!response.ok) {
      throw new Error('Invalid credentials');
    }

    // Receive a short-lived access token (your "key card") in the response body.
    // The long-lived refresh token is set by the server as an HttpOnly cookie
    // and is never exposed to JavaScript. See "Token Management" below for how
    // to hold the access token in memory rather than in localStorage.
    const { accessToken, user } = await response.json();
    return { accessToken, user };
  } catch (error) {
    console.error('Authentication failed:', error);
    throw error;
  }
}

Authorization (What can you access?)

Authorization is like using your key card at different doors:

  • Your key card works only for your room
  • It might also work for the gym during certain hours
  • It won’t work for other guests’ rooms or staff areas

Notice that in the Authorization example above, we don’t need to re-authenticate your identity nor your registration. Your key card is proof that you are who you say you are, and you have a valid reservation! This is the same way that we can use JWT tokens (or sessions, cookies, etc.) to authenticate users in web applications.

In web applications:

// Authorization example. In the hotel analogy the access token is the
// key card — `useAuth` (defined in the Authentication Flow section below)
// provides it as `accessToken`.
function RoomAccess({ roomNumber, children }) {
  const { user, accessToken } = useAuth();

  // Check if the key card is valid for this room. No `useCallback` here:
  // the check is consumed once, inline, and is not passed as a prop or used
  // as a Hook dependency, so wrapping it would add complexity without any
  // benefit (see https://react.dev/reference/react/useCallback).
  const canAccessRoom = Boolean(user && accessToken && user.assignedRooms.includes(roomNumber));

  // If no access, show "wrong room" message
  if (!canAccessRoom) {
    return <div>Access Denied: Wrong Room</div>;
  }

  return children;
}

// Usage example
function HotelApp() {
  return (
    <div>
      {/* Public area - no key card needed */}
      <Lobby />

      {/* Need valid key card */}
      <RoomAccess roomNumber="101">
        <GuestRoom />
      </RoomAccess>

      {/* Need staff key card */}
      <RoomAccess roomNumber="STAFF_ONLY">
        <ServiceArea />
      </RoomAccess>
    </div>
  );
}

Key Differences

Diagram: Key Differences Between Authentication and Authorization

Aspect Authentication Authorization
Purpose Verifies identity Determines access rights
Timing Happens first Happens after authentication
Question “Who are you?” “Can you access this?”
Hotel Analogy Checking in at front desk Using key card at room door
Implementation Login forms, JWT tokens Role checks, permission gates

🎥 Video: Authentication vs Authorization

Authentication Flow

Here’s a visual representation of a basic authentication flow:

Diagram: Basic Authentication Flow

1. User Authentication

Login Flow Implementation

Here’s a basic implementation of a login form with authentication state management:

// AuthContext.js
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
// Shared in-memory access-token store, defined in the "Token Management"
// section below. An ES module is only evaluated once, so this provider,
// useSession, and the axios interceptors defined alongside the store all
// read the same single copy of the token.
import { TokenService } from './tokenService';

// Create authentication context
const AuthContext = createContext(null);

// Auth Provider component
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  // Hold the short-lived access token in React state (in memory). It is
  // cleared on page refresh; we recover it by calling /refresh, which reads
  // the HttpOnly refresh-token cookie the server set at login time.
  // There are deliberately two in-memory copies of the access token: this
  // state value, which triggers re-renders for components that read it via
  // useAuth (like RoomAccess above), and the TokenService store, which
  // non-React code (the axios request interceptor and useSession's session
  // check below) reads at request time. Every handler in this provider
  // writes to both — if TokenService were left empty, every request built
  // from it would go out without an Authorization header.
  const [accessToken, setAccessToken] = useState(null);
  const [loading, setLoading] = useState(true);

  // On mount, try to restore the session using the refresh cookie.
  useEffect(() => {
    (async () => {
      try {
        const response = await fetch('https://api.example.com/refresh', {
          method: 'POST',
          credentials: 'include', // send HttpOnly refresh cookie
        });
        if (response.ok) {
          const data = await response.json();
          // Write through to the shared store, then mirror into React state.
          TokenService.setToken(data.accessToken);
          setAccessToken(data.accessToken);
          setUser(data.user);
        }
      } catch {
        // No active session — user will need to log in.
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  // Login function
  const login = async (credentials) => {
    try {
      setLoading(true);
      const response = await fetch('https://api.example.com/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        // Allow the server to set the HttpOnly refresh-token cookie.
        credentials: 'include',
        body: JSON.stringify(credentials),
      });

      if (!response.ok) {
        throw new Error('Login failed');
      }

      const data = await response.json();

      // Keep the access token in memory rather than localStorage. That keeps
      // it out of persistent storage an injected script could enumerate at
      // leisure, but it is not XSS-proof: injected script runs with the same
      // privileges as application code and can still call
      // TokenService.getToken(), so preventing XSS remains essential.
      // Write the token through to the shared TokenService store — that is
      // where the apiClient request interceptor and useSession's session
      // check read it from — and mirror it into React state for components
      // that render against it.
      TokenService.setToken(data.accessToken);
      setAccessToken(data.accessToken);
      setUser(data.user);

      return data;
    } catch (error) {
      console.error('Login error:', error);
      throw error;
    } finally {
      setLoading(false);
    }
  };

  // Logout — also asks the server to clear the HttpOnly refresh cookie.
  // Wrapped in useCallback because useSession (Session Management section
  // below) lists `logout` in its effect dependency array: without the wrapper,
  // every provider re-render would create a new `logout` function, tearing
  // down and re-creating the session-check interval (and re-firing
  // checkSession) each time. The empty dependency array is safe — set
  // functions like setAccessToken and setUser have stable identities, and
  // TokenService is a module-level singleton. (`login` stays unwrapped per
  // the RoomAccess note above: nothing consumes its identity.)
  const logout = useCallback(async () => {
    try {
      await fetch('https://api.example.com/logout', {
        method: 'POST',
        credentials: 'include',
      });
    } catch (error) {
      // The /logout request can fail (e.g. a network error). Without this
      // catch the rejection would propagate past `finally`, and callers like
      // useSession's checkSession invoke logout() without awaiting it — so
      // the failure would surface as an unhandled promise rejection. Local
      // state is still cleared below either way.
      console.error('Logout request failed:', error);
    } finally {
      // Clear both in-memory copies of the access token.
      TokenService.clearToken();
      setAccessToken(null);
      setUser(null);
    }
  }, []);

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

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

// Login component
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const { login, loading } = useAuth();
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await login({ email, password });
      // Redirect to dashboard or home page
    } catch (err) {
      setError('Invalid credentials');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

🎥 Video: Why Email Only Auth Is More Secure

2. Protected Routes

Once we have an authenticated user, we can implement a ProtectedRoute component to protect routes from unauthorized access. This is a common pattern in React applications to ensure that users are authenticated before they can access protected routes.

import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';

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

  // Show loading state while checking authentication
  if (loading) {
    return <div>Loading...</div>;
  }

  // Redirect to login if not authenticated
  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  // Check role-based access if required
  if (requiredRole && user.role !== requiredRole) {
    return <Navigate to="/unauthorized" replace />;
  }

  return children;
}

// Usage in router
function AppRouter() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route path="/public" element={<PublicPage />} />
      <Route
        path="/dashboard"
        element={
          <ProtectedRoute>
            <DashboardPage />
          </ProtectedRoute>
        }
      />
      <Route
        path="/admin"
        element={
          <ProtectedRoute requiredRole="admin">
            <AdminPage />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}

Protected routes flow:

Diagram: Protected Routes Flow

🎥 Video: Protected Routes in react router version 6

3. Token Management

Once a user logs in, the server issues two tokens: a short-lived access token the client sends with every API request, and a long-lived refresh token the client uses to obtain new access tokens. Where you store each one matters — any script running on the page can read localStorage or sessionStorage, so storing session tokens there exposes them to cross-site scripting (XSS) attacks. The OWASP session-management guidance recommends keeping the access token in memory (a JavaScript closure or React state) and having the server set the refresh token as an HttpOnly; Secure; SameSite=Strict cookie so it never touches JavaScript at all. OAuth 2.0 for Browser-Based Applications (RFC 10017, the IETF Best Current Practice for OAuth in the browser) goes further and recommends a Backend-for-Frontend (BFF) when threat model allows, keeping all OAuth tokens server-side and exposing only an HttpOnly session cookie to the browser. The example below applies the in-memory + HttpOnly cookie pattern. One subtlety in its 401 handler: with refresh-token rotation (which RFC 9700 §4.14.2 and RFC 10017 §6.3.2.3 require of authorization servers for browser-based clients unless refresh tokens are sender-constrained), every refresh token is single-use, so concurrent refresh attempts must be funneled through one shared request — see the single-flight refreshAccessToken() helper in the interceptor code. The TokenService store it defines is the same one AuthProvider (Authentication Flow section above) writes into on login, session restore, and logout — that write-through is what puts the Authorization header on every request apiClient and useSession make.

// tokenService.js
import axios from 'axios';

// In-memory token store. The access token lives in this module's closure,
// so it is not exposed via `localStorage`/`sessionStorage` to scripts
// injected via XSS. The long-lived refresh token is kept off the JavaScript
// heap entirely: the server sets it as an HttpOnly, Secure, SameSite=Strict
// cookie at login time and reads it directly on /refresh requests.
// AuthProvider (Authentication Flow section above) calls setToken here on
// login and session restore and clearToken on logout; since a module is
// evaluated only once, every importer shares this single `accessToken`.
let accessToken = null;

export const TokenService = {
  getToken: () => accessToken,

  setToken: (token) => {
    accessToken = token;
  },

  clearToken: () => {
    accessToken = null;
  },

  // Add token to API requests
  getAuthHeader: () => {
    return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
  },

  // Check if token is expired. JWT segments are base64url-encoded
  // (RFC 7519 §3 / RFC 7515 §2), which is a URL-safe variant of base64:
  // `-` / `_` replace `+` / `/` and trailing `=` padding is omitted.
  // `atob` only accepts standard base64, so we translate first.
  isTokenExpired: (token = accessToken) => {
    if (!token) return true;
    try {
      const payload = token.split('.')[1];
      const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
      const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
      const decoded = JSON.parse(atob(padded));
      return decoded.exp < Date.now() / 1000;
    } catch (e) {
      return true;
    }
  }
};

// API client with token management. The shared `apiClient` carries the
// access-token interceptor and the 401-refresh interceptor.
export const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  // Send cookies on cross-origin requests to the API. (The /refresh call
  // itself goes through the separate `refreshClient` below, which also
  // sets this flag so the browser attaches the HttpOnly refresh cookie.)
  withCredentials: true,
});

// Separate, interceptor-free client for the refresh call itself. Using a
// dedicated instance avoids an infinite loop where a 401 from `/refresh`
// would re-trigger the response interceptor below.
const refreshClient = axios.create({
  baseURL: 'https://api.example.com',
  withCredentials: true,
});

// Add the in-memory access token to outgoing requests.
apiClient.interceptors.request.use(
  (config) => {
    const token = TokenService.getToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// On 401, exchange the HttpOnly refresh cookie for a new access token
// and retry the request once.
//
// The refresh is "single-flight": when several requests fail with 401 at
// the same time (an expired access token usually breaks every in-flight
// call at once), only the FIRST one sends /refresh — the rest await the
// same shared promise. This matters because the Best Practices section
// below recommends refresh-token rotation (RFC 9700 §4.14.2), under which
// each refresh token is single-use: the server invalidates it on use, and
// treats a reuse as evidence of theft by revoking the active refresh
// token. If every concurrent 401 fired its own /refresh, the second call
// would present the just-invalidated cookie — the server would kill the
// whole session, and the user would land on /login moments after a
// perfectly good refresh.
let refreshPromise = null;

function refreshAccessToken() {
  if (!refreshPromise) {
    // Use the interceptor-free `refreshClient` so a failed refresh does
    // not re-enter the 401 handler. The browser automatically attaches
    // the HttpOnly refresh cookie.
    refreshPromise = refreshClient
      .post('/refresh')
      .then(({ data }) => {
        // Update the shared store; subsequent requests read the new token
        // from here. (AuthProvider's React-state copy is not updated by
        // this interceptor — that copy is only used for rendering checks,
        // while requests always read TokenService.)
        TokenService.setToken(data.accessToken);
        return data.accessToken;
      })
      .finally(() => {
        // Clear the slot whether the refresh succeeded or failed, so the
        // next expiry (or a retry after a transient network error) starts
        // a fresh single-flight refresh instead of reusing a settled one.
        refreshPromise = null;
      });
  }
  return refreshPromise;
}

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      // `_retry` is per-request: each failed request still retries at most
      // once, but all the retries wait on the one shared refresh.
      originalRequest._retry = true;

      try {
        await refreshAccessToken();
        // Retry the original request. The request interceptor above runs
        // again on the retry and overwrites the stale Authorization header
        // with the new token it reads from TokenService.
        return apiClient(originalRequest);
      } catch (refreshError) {
        // Refresh failed — the session is gone for every waiting request.
        // Clear in-memory state and force re-login. (All concurrent
        // waiters land here; the repeated assignments to
        // window.location.href are harmless.)
        TokenService.clearToken();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

Token lifecycle:

Diagram: Token Lifecycle

4. Session Management

Sessions are a way to keep a user logged in after they have logged in. This is typically done through a combination of a token and a refresh token. The refresh token is used to get a new access token when the current access token expires.

import { useState, useEffect } from 'react';
import { useAuth } from './AuthContext';
// The same shared store AuthProvider writes into at login time.
import { TokenService } from './tokenService';

function useSession() {
  const { user, logout } = useAuth();
  const [sessionExpired, setSessionExpired] = useState(false);

  useEffect(() => {
    let sessionCheckInterval;

    const checkSession = async () => {
      try {
        // TokenService was populated by AuthProvider at login (or session
        // restore), so this request carries the current access token.
        const response = await fetch('/api/check-session', {
          headers: TokenService.getAuthHeader(),
        });

        if (!response.ok) {
          throw new Error('Session invalid');
        }
      } catch (error) {
        setSessionExpired(true);
        // Fire-and-forget is safe here: logout catches its own fetch failure
        // (see AuthProvider above), so this call can never reject.
        logout();
      }
    };

    if (user) {
      // Check session every 5 minutes
      sessionCheckInterval = setInterval(checkSession, 5 * 60 * 1000);
      checkSession();
    }

    return () => {
      if (sessionCheckInterval) {
        clearInterval(sessionCheckInterval);
      }
    };
  }, [user, logout]);

  return { sessionExpired };
}

Session flow:

Diagram: Session Flow

5. Role-Based Access Control (RBAC)

Role-based access control (RBAC) is a way to control access to resources based on the roles of the users. This is an authorization pattern (see the Key Differences table above): the user’s identity is already verified, and RBAC decides what that authenticated user is allowed to see and do.

import { useCallback } from 'react';
import { useAuth } from './AuthContext';

// Define permission levels
const Permissions = {
  READ: 'read',
  WRITE: 'write',
  ADMIN: 'admin'
};

// Role definitions, keyed by the same lowercase `user.role` strings that
// ProtectedRoute compares against above (e.g. requiredRole="admin").
const Roles = {
  user: {
    permissions: [Permissions.READ]
  },
  editor: {
    permissions: [Permissions.READ, Permissions.WRITE]
  },
  admin: {
    permissions: [Permissions.READ, Permissions.WRITE, Permissions.ADMIN]
  }
};

// Permission check hook
function usePermissions() {
  const { user } = useAuth();
  
  const hasPermission = useCallback((permission) => {
    if (!user || !user.role) return false;
    // Optional chaining guards against a role with no entry in `Roles`,
    // so an unknown role denies access instead of throwing.
    return Roles[user.role]?.permissions.includes(permission) ?? false;
  }, [user]);

  return { hasPermission };
}

// Protected component based on permissions
function PermissionGate({ permission, children }) {
  const { hasPermission } = usePermissions();

  if (!hasPermission(permission)) {
    return null;
  }

  return children;
}

// Usage example
function AdminPanel() {
  return (
    <PermissionGate permission={Permissions.ADMIN}>
      <div>Admin Only Content</div>
    </PermissionGate>
  );
}

RBAC flow:

Diagram: RBAC Flow

🎥 Video: Who’s allowed to do what? RBAC vs ABAC & The Art of Access Control
🎥 Video: Implementing RBAC in React

Best Practices

  1. Security

    • Never store session credentials (access tokens, refresh tokens, JWTs) in localStorage or sessionStorage — any script on the page can read them, so an XSS injection exfiltrates the whole session
    • Hold the access token in memory (a JavaScript closure or React state) and have the server issue the refresh token as an HttpOnly; Secure; SameSite=Strict cookie so JavaScript cannot read it
    • For maximum hardening, use a Backend-for-Frontend (BFF) that keeps all OAuth tokens server-side and exposes only a session cookie to the browser
    • Use HTTPS for all API requests
    • Implement token refresh and rotation; revoke refresh tokens on logout
    • Handle session timeouts gracefully
  2. User Experience

    • Show loading states during authentication
    • Provide clear error messages
    • Redirect users to intended destination after login
    • Maintain session state across page refreshes
  3. Code Organization

    • Centralize authentication logic
    • Use custom hooks for reusable auth functionality
    • Implement proper error boundaries
    • Follow the principle of least privilege