TL;DR: Learn how to secure your React app with JWT authentication. Start by setting up three route types: public for everyone, protected for logged-in users, and restricted for login or signup only. Then, create an AuthContext to manage login, logout, and token refresh across the app. Finally, add API services—public for authentication actions and authenticated ones that attach tokens and handle expiry. With this setup, your app will be secure, scalable, and deliver a smooth experience for users.
Modern web apps rely on decoupled frontends and backends. They often communicate via stateless REST (Representational State Transfer) APIs. Since the server doesn’t track user identity by default, sensitive data can be exposed without proper authentication. That’s why adding a secure method, such as JWT, is essential to protect your app and its users.
JWT provides a lightweight, stateless method for verifying user identity across requests. Implementing it correctly in your React apps ensures:
- Secure access control for routes and APIs.
- Scalable architecture without session overhead.
- Better user experience with token refresh and auto-logout.
JWT: An overview
JWT (JSON Web Tokens) is a popular tokenization method in which the server returns a signed token to the client upon successful user login. This token is then stored on the client-side and is sent in every subsequent request to the server. It establishes the authenticity of the request and then returns the response accordingly.
Once the token has expired, a refreshed token is returned, and the flow continues. Upon logout, we discard the token to prevent it from being misused.
The token is signed with the user’s details and other information, depending on its configuration.
Important note: As the JWT token is stored on the client-side, it is accessible to the end user. We should not store sensitive information in the token, as it is only signed and encoded using Base64URL and not encrypted.
A JWT token is composed of three different parts separated by a dot:
- Header
- Payload
- Signature
Refer to the following example.
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const [headerEncoded, payloadEncoded, signature] = token.split('.');
Implementing JWT authentication in a React app
The following image illustrates the working principle of JWT authentication.
To implement the above logic, we need to create the following routes:
- Public routes: Open to everyone, no login required. Perfect for pages like About Us or Contact.
- Protected routes: Accessible only after authentication. If you’re not logged in, you’ll be redirected to the login page. Great for dashboards or user profiles.
- Restricted routes: Designed for unauthenticated users. If you’re already logged in, you’ll be sent to the dashboard instead. Ideal for login or signup pages.
Then, we’ll take things a step further by centralizing all authentication logic. To do this, we’ll create an AuthContext and wrap our routes inside it. This ensures that the authentication state and related actions are easily accessible anywhere in the app.
Next, we’ll set up API services:
- Public APIs: Handle authentication actions that don’t need a token.
- Restricted APIs: Automatically attach the token to every request. They’ll also intercept responses to check for token-related errors, such as an expired token, and log the user out if necessary.
With this flow in place, we’re ready to start coding our React app and bring authentication to life.
Step 1: Install the dependencies
First, set up the project and install the required packages:
npx create-react-app react-jwt-auth
cd react-jwt-auth
npm install axios jwt-decode react-router-dom
Here’s what each dependency does:
-
axios: A powerful HTTP client that supports middlewares and request interceptors, making API calls easier to manage. -
jwt-decode: A handy library to decode any well-formed JWT token. -
react-router-dom: Enables smooth client-side navigation in your React app.
Next, create the directory structure and add the necessary files to organize your authentication flow.
Step 2: Create the AuthContext
Next, we’ll create an AuthContext, which will act as the central hub for all authentication logic. It will:
- Handle login and logout actions to easily manage user sessions.
- Maintain user state and loading state, allowing you to check if the user is logged in or if the app is still fetching data.
- Provide these states and actions to any component wrapped inside the context, making authentication accessible throughout the app.
Look at the example code below.
//contexts/AuthContext.jsx
import React, { createContext, useContext, useState, useEffect } from "react";
import authService from "../services/authService";
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const initializeAuth = async () => {
try {
// Check if we have a valid session by calling the server
const userData = await authService.checkAuthStatus();
if (userData) {
setUser(userData);
}
} catch (error) {
console.error("Auth initialization error:", error);
// Don't logout here as cookies might still be valid
} finally {
setLoading(false);
}
};
initializeAuth();
}, []);
const login = async (username, password) => {
try {
const userData = await authService.login(username, password);
setUser(userData);
return userData;
} catch (error) {
throw error;
}
};
const logout = async () => {
try {
await authService.logout();
} catch (error) {
console.error("Logout error:", error);
} finally {
setUser(null);
}
};
// Refresh user data from current token
const refreshUser = () => {
const userData = authService.getCurrentUser();
setUser(userData);
return userData;
};
const value = {
user,
login,
logout,
refreshUser,
loading,
isAuthenticated: !!user && !!authService.getAccessToken(),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
Step 3: Creating API services
Here, we’ll create two separate services:
- Public APIs
- Authenticated APIs
Public APIs
- These work without token access and handle actions such as sign-up, login, and logout.
- After login, the token is stored in memory under the context for persistence.
- If the user refreshes the page, we retrieve the token from the cookie and store it in memory again. (We use cookies instead of local storage to avoid XSS attacks.)
- User details are fetched by decoding the JWT token and stored in memory.
- Access tokens stored in memory are cleared or refreshed on page reload or close.
//services/authService.js
import axios from "axios";
import jwtDecode from "jwt-decode";
const API_URL = process.env.REACT_APP_API_URL || "http://localhost:8080/api/auth/";
// Mock API calls (replace with real endpoints)
const api = {
// JWT Login
login: async (email, password) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
if (email && password) {
return {
data: {
id: 1,
email,
username: "John Doe",
accessToken: "mock_jwt_access_token_" + Date.now(),
refreshToken: "mock_jwt_refresh_token_" + Date.now(),
},
};
}
throw new Error("Invalid credentials");
},
};
class AuthService {
constructor() {
this.isRefreshing = false;
this.failedQueue = [];
this.accessToken = null; // Store in memory only
}
// Configure axios to include cookies in requests
configureAxios() {
axios.defaults.withCredentials = true;
}
async login(username, password) {
try {
const response = await axios.post(
API_URL + "signin",
{ username, password },
{ withCredentials: true } // Include cookies in request
);
// Access token should be returned in response body for memory storage
// Refresh token should be set as httpOnly cookie by server
if (response.data.accessToken) {
this.accessToken = response.data.accessToken;
return this.decodeToken(response.data.accessToken);
}
throw new Error("No access token received");
} catch (error) {
throw error;
}
}
logout() {
// Clear memory token
this.accessToken = null;
this.isRefreshing = false;
this.failedQueue = [];
// Call logout endpoint to clear httpOnly cookie
return axios
.post(API_URL + "logout", {}, { withCredentials: true })
.catch((error) => {
console.error("Logout error:", error);
});
}
register(username, email, password) {
return axios.post(
API_URL + "signup",
{ username, email, password, },
{ withCredentials: true }
);
}
// Decode JWT to get user information
decodeToken(token = null) {
const tokenToUse = token || this.accessToken;
if (!tokenToUse) {
return null;
}
try {
const decoded = jwtDecode(tokenToUse);
// Check if token is expired
const currentTime = Date.now() / 1000;
if (decoded.exp < currentTime) {
this.accessToken = null;
return null;
}
return {
id: decoded.sub || decoded.id,
username: decoded.username,
email: decoded.email,
roles: decoded.roles || [],
exp: decoded.exp,
iat: decoded.iat,
};
} catch (error) {
console.error("Token decode error:", error);
this.accessToken = null;
return null;
}
}
getCurrentUser() {
return this.decodeToken();
}
getAccessToken() {
return this.accessToken;
}
// Check if token is expired or will expire soon (within 5 minutes)
isTokenExpired(token = null) {
const tokenToUse = token || this.accessToken;
if (!tokenToUse) return true;
try {
const decoded = jwtDecode(tokenToUse);
const currentTime = Date.now() / 1000;
const bufferTime = 5 * 60; // 5 minutes buffer
return decoded.exp < currentTime + bufferTime;
} catch (error) {
return true;
}
}
async refreshToken() {
try {
// Refresh token is sent automatically as httpOnly cookie
const response = await axios.post( API_URL + "refresh", {}, { withCredentials: true, });
if (response.data.accessToken) {
this.accessToken = response.data.accessToken;
return response.data.accessToken;
}
throw new Error("No access token in refresh response");
} catch (error) {
// Refresh failed, clear memory token
this.accessToken = null;
throw error;
}
}
// Check authentication status by calling a protected endpoint
async checkAuthStatus() {
try {
const response = await axios.get(API_URL + "me", {
withCredentials: true,
headers: this.accessToken ? { Authorization: `Bearer ${this.accessToken}`, } : {},
});
if (response.data.accessToken) {
this.accessToken = response.data.accessToken;
}
return this.decodeToken();
} catch (error) {
this.accessToken = null;
return null;
}
}
// Process queued requests after token refresh
processQueue(error, token = null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
this.failedQueue = [];
}
// Add request to queue during token refresh
addToQueue(resolve, reject) {
this.failedQueue.push({ resolve, reject });
}
}
// Create singleton instance
const authService = new AuthService();
// Configure axios globally
authService.configureAxios();
export default authService;
Authenticated APIs
This service uses request and response interceptors:
- Request interceptor: Automatically attaches the token to every API call.
- Response interceptor: Checks if the token has expired. If so, it logs out the user and forces re-authentication.
//services/apiService.js
import axios from "axios";
import authService from "./authService";
const API_BASE_URL =
process.env.REACT_APP_API_URL || "http://localhost:8080/api/";
// Create axios instance with secure defaults
const apiService = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
withCredentials: true, // Always include cookies
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token from memory
apiService.interceptors.request.use(
(config) => {
const token = authService.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor with secure refresh token handling
apiService.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Handle 401 errors with token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
if (authService.isRefreshing) {
// Queue request if refresh is in progress
return new Promise((resolve, reject) => {
authService.addToQueue(resolve, reject);
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiService(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
authService.isRefreshing = true;
try {
const newToken = await authService.refreshToken();
// Process queued requests
authService.processQueue(null, newToken);
// Retry original request
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiService(originalRequest);
} catch (refreshError) {
// Refresh failed, process queue with error
authService.processQueue(refreshError, null);
// Clear tokens and redirect to login
await authService.logout();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
authService.isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default apiService;
Step 4: Configuring the routes
Now, let’s create Protected and Authenticated route wrappers. These will redirect users to the correct page based on whether they are authenticated (i.e., the JWT token is valid).
Protected route
- It requires authentication.
- If the user is not logged in, they are redirected to the login page.
//components/ProtectedRoute.jsx
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
// Redirect to login page with return url
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default ProtectedRoute;
Authenticated route
- It cannot be accessed if the user is already logged in.
- If the user tries to access the login page while logged in, they are redirected to the dashboard page.
//components/AuthenticatedRoute.jsx
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const AuthenticatedRoute = ({ children }) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>;
}
if (user) {
// Redirect to dashboard page with return url
return <Navigate to="/dashboard" state={{ from: location }} replace />;
}
return children;
};
export default AuthenticatedRoute;
Adding the routes to the application
Now, configure the entry index file by defining the routes and wrapping them inside AuthContext.
In the following example, the respective protected and restricted routes are wrapped inside the component.
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import ProtectedRoute from "./components/ProtectedRoute";
import AuthenticatedRoute from "./components/AuthenticatedRoute";
import Login from "./components/Login";
import Dashboard from "./components/Dashboard";
import About from "./components/About";
import "./styles.css";
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Routes>
<Route
path="/login"
element={
<AuthenticatedRoute>
<Login />
</AuthenticatedRoute>
}
/>
<Route path="/about-us" element={<About />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<h1>404</h1>} />
</Routes>
</div>
</Router>
</AuthProvider>
);
}
export default App;
In the above code example,
-
/login: It is an authenticated route that cannot be accessed if the user is already logged in. -
/dashboard: It is a protected route that cannot be accessed if the user is not logged in. -
/about-us: It is a public route that can be accessed without any restriction. - Any other route apart from these will throw a 404 error.
Step 5: Designing the pages
We’re now building three key pages that shape the core experience:
- Login
- Dashboard
- About us
Login
The login page uses the login method that is exposed from the AuthContext. Once the login is successful (mocked with a non-empty Email and Password), it generates a dummy auth token and stores it in the storage. It will then be passed to other subsequent requests.
// components/Login.jsx
import React, { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const Login = () => {
const [formData, setFormData] = useState({
email: "",
password: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/dashboard";
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(formData.email, formData.password);
navigate(from, { replace: true });
} catch (error) {
console.log(error);
setError(error.response?.data?.message || "Login failed");
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<form onSubmit={handleSubmit} className="login-form">
<h2>Login</h2>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
autoComplete={"off"}
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ?"Logging in...": "Login"}
</button>
</form>
</div>
);
};
export default Login;
After executing the above code example, our login page will be created like in the following image.
Dashboard
The dashboard is only visible when the user is logged in or has a valid access token. When the page loads, it automatically makes a network request to fetch dashboard data, sending the Bearer JWT token in the Authorization header to ensure secure access.
// components/Dashboard.jsx
import React, { useState, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { useTokenMonitor } from "../hooks/useTokenMonitor";
import apiService from "../services/apiService";
const Dashboard = () => {
const { user, logout } = useAuth();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Monitor token expiration
useTokenMonitor();
useEffect(() => {
fetchProtectedData();
}, []);
const fetchProtectedData = async () => {
try {
setLoading(true);
const response = await apiService.get("/protected-endpoint");
setData(response.data);
setError("");
} catch (error) {
console.error("Failed to fetch data:", error);
setError("Failed to fetch data");
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
await logout();
};
if (loading) return <div>Loading...</div>;
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>Dashboard</h1>
<div className="user-info">
<span>Welcome, {user?.username}!</span>
<span className="user-email">({user?.email})</span>
<button onClick={fetchProtectedData}>Refresh Data</button>
<button onClick={handleLogout}>Logout</button>
</div>
</header>
<main className="dashboard-content">
{error && <div className="error-message">{error}</div>}
<div className="user-details">
<h3>User Information (from JWT)</h3>
<pre>{JSON.stringify(user, null, 2)}</pre>
</div>
<div className="data-section">
<h2>Protected Data</h2>
{data? (
<pre>{JSON.stringify(data, null, 2)}</pre>
) : (
<p>No data available</p>
)}
</div>
</main>
</div>
);
};
export default Dashboard;
Here’s what the output looks like.
Here, the API request fails as we are making a genuine request.
Monitor token
Next, we’ll create a custom hook to monitor the token used in the Dashboard component. This hook checks whether the token has expired and, if needed, requests a refresh to maintain authentication.
If the refresh attempt fails, the user is logged out and prompted to sign in again for security.
// src/hooks/useTokenMonitor.js
import { useEffect, useCallback } from "react";
import { useAuth } from "../contexts/AuthContext";
import authService from "../services/authService";
export const useTokenMonitor = () => {
const { refreshUser, logout } = useAuth();
const checkTokenExpiration = useCallback(async () => {
const token = authService.getAccessToken();
if (!token) {
return;
}
// Check if token is expired or will expire soon
if (authService.isTokenExpired(token)) {
try {
await authService.refreshToken();
// Update user data from new token
refreshUser();
} catch (error) {
console.error("Token refresh failed:", error);
logout();
}
}
}, [refreshUser, logout]);
useEffect(() => {
// Check token expiration every 5 minutes
const interval = setInterval(checkTokenExpiration, 5 * 60 * 1000);
// Check immediately on mount
checkTokenExpiration();
// Listen for focus events to check token when user returns to tab
const handleFocus = () => {
checkTokenExpiration();
};
window.addEventListener("focus", handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener("focus", handleFocus);
};
}, [checkTokenExpiration]);
return { checkTokenExpiration };
};
About-us
Finally, let’s design the About-us page with a header tag that introduces your About section.
// components/About.js
const About = () => {
return <h1>About us</h1>;
};
export default About;
Refer to the following image.
Style.css
Here’s the code example for the styles used in the entire application.
.App {
text-align: center;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
}
.login-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.form-group {
margin-bottom: 1rem;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
width: 100%;
padding: 0.75rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.dashboard {
min-height: 100vh;
background-color: #f8f9fa;
}
.dashboard-header {
background: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.dashboard-content {
padding: 2rem;
}
.data-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: left;
}
GitHub reference
Also, refer to the implementation of JWT authentication in the React app demo on GitHub.
Conclusion
Thanks for reading! Implementing JWT authentication can seem tricky, and mistakes often lead to security gaps. By following best practices like proper token expiry and refresh mechanisms, you can keep your app secure and user sessions smooth.
Ready to take this further? Start integrating these steps into your project today and share your experience with us!
Got questions or ideas? Drop them in the comments or reach out via our support forum, support portal, and feedback portal. We’d love to hear from you!
Related Blogs
- React Spreadsheet AWS S3 Integration: Open and Save Excel Files in the Cloud
- How to Deploy a Spreadsheet Server on Azure App Service Using Visual Studio and Docker
- How to Prevent XSS Attacks in React Rich Text Editor
- How to Embed and Edit PDFs in React Using the React PDF Viewer
This article was originally published at Syncfusion.com
![JWT Authentication workflow]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.syncfusion.com%2Fblogs%2Fwp-content%2Fuploads%2F2025%2F12%2FJWT-Authentication-workflow.png)





Top comments (0)