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:

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.


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
function RoomAccess({ roomNumber, children }) {
const { user, keyCard } = 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 && keyCard && 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

| 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:

1. User Authentication
Login Flow Implementation
Here’s a basic implementation of a login form with authentication state management:
import { useState, useEffect, createContext, useContext } from 'react';
// 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.
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();
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 component state rather than localStorage,
// so it cannot be read by injected JavaScript.
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.
const logout = async () => {
try {
await fetch('https://api.example.com/logout', {
method: 'POST',
credentials: 'include',
});
} finally {
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: Email Authentication Explained
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, 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:

🎥 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. The IETF draft OAuth 2.0 for Browser-Based Apps 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.
// 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.
let accessToken = null;
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.
const apiClient = axios.create({
baseURL: 'https://api.example.com',
// Send the HttpOnly refresh-token cookie on cross-origin /refresh and
// /logout calls. For same-origin APIs, cookies are sent automatically.
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.
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Use the interceptor-free `refreshClient` so a failed refresh
// does not re-enter this handler. The browser automatically
// attaches the HttpOnly refresh cookie.
const { data } = await refreshClient.post('/refresh');
TokenService.setToken(data.accessToken);
// Retry original request with the new access token.
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed — clear in-memory state and force re-login.
TokenService.clearToken();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
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.
function useSession() {
const { user, setUser } = useAuth();
const [sessionExpired, setSessionExpired] = useState(false);
useEffect(() => {
let sessionCheckInterval;
const checkSession = async () => {
try {
const response = await fetch('/api/check-session', {
headers: TokenService.getAuthHeader(),
});
if (!response.ok) {
throw new Error('Session invalid');
}
} catch (error) {
setSessionExpired(true);
setUser(null);
}
};
if (user) {
// Check session every 5 minutes
sessionCheckInterval = setInterval(checkSession, 5 * 60 * 1000);
checkSession();
}
return () => {
if (sessionCheckInterval) {
clearInterval(sessionCheckInterval);
}
};
}, [user, setUser]);
return { sessionExpired };
}
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 a common pattern in React applications to ensure that users are authenticated before they can access protected routes.
// Define permission levels
const Permissions = {
READ: 'read',
WRITE: 'write',
ADMIN: 'admin'
};
// Role definitions
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;
return Roles[user.role].permissions.includes(permission);
}, [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:

🎥 Video: Who’s allowed to do what? RBAC vs ABAC & The Art of Access Control
🎥 Video: Implementing RBAC in React
Best Practices
-
Security
- Never store session credentials (access tokens, refresh tokens, JWTs) in
localStorageorsessionStorage— 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=Strictcookie 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
- Never store session credentials (access tokens, refresh tokens, JWTs) in
-
User Experience
- Show loading states during authentication
- Provide clear error messages
- Redirect users to intended destination after login
- Maintain session state across page refreshes
-
Code Organization
- Centralize authentication logic
- Use custom hooks for reusable auth functionality
- Implement proper error boundaries
- Follow the principle of least privilege