JSON Web Tokens are a widely used approach to stateless authentication. The server issues a signed token when the user logs in, the client stores it and sends it with subsequent requests, and the server verifies the signature before processing each request. There is no session store on the server to query — the token itself carries the authentication state.
This guide builds a complete JWT authentication flow in React: a login form, token storage, protected routes, authenticated API requests, and the security considerations that matter for production use.
What this covers:
How JWTs are structured and verified
A Node.js backend that issues tokens on login
A React login form that stores the token
Protected routes with React Router
Sending authenticated requests
Token storage security:
localStoragevshttpOnlycookiesToken expiry, refresh tokens, and logout
How JWT Works
A JSON Web Token has three parts, separated by dots:
header.payload.signatureThe header specifies the token type and signing algorithm. The payload contains claims — data about the user, such as their ID and roles. The signature is a hash of the header and payload signed with a secret key. The server verifies the signature on each request to confirm the token has not been tampered with.
A decoded payload might look like:
{
"userId": 1,
"role": "admin",
"iat": 1714000000,
"exp": 1714003600
}iat is the issue time. exp is the expiry time. The server rejects tokens where exp is in the past.
The token is not encrypted by default — the payload is base64-encoded and readable by anyone who has the token. Do not put sensitive data in the payload. The security guarantee is the signature: without the server's secret key, the payload cannot be modified without invalidating the signature.
Step 1: Backend — Issue Tokens on Login
A minimal Express backend that issues JWTs:
// server.js
import express from "express";
import jwt from "jsonwebtoken";
import cors from "cors";
const app = express();
app.use(express.json());
app.use(cors({ origin: "http://localhost:3000", credentials: true }));
const SECRET = process.env.JWT_SECRET; // never hardcode in production
app.post("/login", (req, res) => {
const { username, password } = req.body;
// Replace with a real database check
if (username === "admin" && password === "password") {
const token = jwt.sign(
{ userId: 1, username },
SECRET,
{ expiresIn: "15m" }
);
// Option A: return in response body (client stores in memory or localStorage)
return res.json({ token });
// Option B: set as httpOnly cookie (more secure for web apps)
// res.cookie("token", token, {
// httpOnly: true,
// secure: process.env.NODE_ENV === "production",
// sameSite: "strict",
// maxAge: 15 * 60 * 1000,
// });
// return res.json({ success: true });
}
res.status(401).json({ message: "Invalid credentials" });
});
app.listen(5000);Two delivery options are shown and commented. The response body approach is simpler to implement. The httpOnly cookie approach is more secure for web applications because JavaScript cannot access httpOnly cookies, which prevents XSS attacks from stealing the token. The tradeoff is more complex logout handling and CORS configuration.
The expiresIn: "15m" is intentionally short. Short-lived access tokens limit the window of exposure if a token is stolen. Pair this with refresh tokens (covered below) to maintain sessions without requiring frequent logins.
Step 2: React Login Form
// Login.jsx
import { useState } from "react";
function Login({ onLoginSuccess }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
try {
const res = await fetch("http://localhost:5000/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include", // required if using cookies
});
if (!res.ok) {
const data = await res.json();
setError(data.message || "Login failed");
return;
}
const data = await res.json();
if (data.token) {
// Store in memory (most secure) or localStorage (convenient but XSS-vulnerable)
sessionStorage.setItem("token", data.token);
}
onLoginSuccess();
} catch {
setError("Network error. Please try again.");
}
};
return (
<form onSubmit={handleSubmit}>
{error && <p role="alert">{error}</p>}
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Log in</button>
</form>
);
}
export default Login;Two improvements over the minimal version most guides show: error state is displayed in the UI rather than via alert(), and failed fetch responses are handled separately from network errors.
Step 3: Protected Routes
A ProtectedRoute component redirects unauthenticated users to the login page:
// ProtectedRoute.jsx
import { Navigate, useLocation } from "react-router-dom";
function ProtectedRoute({ children }) {
const token = sessionStorage.getItem("token");
const location = useLocation();
if (!token) {
// Pass the attempted location so login can redirect back after success
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export default ProtectedRoute;Passing state={{ from: location }} to the redirect allows the login page to redirect the user back to the page they were trying to reach after a successful login:
// In Login.jsx
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/dashboard";
// After successful login:
navigate(from, { replace: true });Router setup:
// App.jsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Login from "./Login";
import Dashboard from "./Dashboard";
import Settings from "./Settings";
import ProtectedRoute from "./ProtectedRoute";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
export default App;Step 4: Sending Authenticated Requests
Include the token in the Authorization header for API requests:
async function fetchProtectedData(endpoint) {
const token = sessionStorage.getItem("token");
const res = await fetch(`http://localhost:5000${endpoint}`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
credentials: "include",
});
if (res.status === 401) {
// Token is invalid or expired — redirect to login
sessionStorage.removeItem("token");
window.location.href = "/login";
return;
}
return res.json();
}Handling 401 responses explicitly prevents the application from silently failing when a token expires. Clearing the stored token and redirecting to login gives the user a clear path to re-authenticate.
On the server, the middleware that verifies the token:
function authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader?.split(" ")[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, SECRET, (err, decoded) => {
if (err) {
// err.name === "TokenExpiredError" for expired tokens
return res.sendStatus(err.name === "TokenExpiredError" ? 401 : 403);
}
req.user = decoded;
next();
});
}
app.get("/protected", authenticateToken, (req, res) => {
res.json({ userId: req.user.userId });
});Distinguishing TokenExpiredError from other verification errors (403) allows the client to handle expired tokens differently from invalid tokens — for example, attempting a token refresh before redirecting to login.
Token Storage Security
The choice of where to store JWTs has security implications:
localStorage/sessionStorage: Accessible via JavaScript, which means an XSS vulnerability can steal the token.localStoragepersists across browser sessions.sessionStorageis cleared when the tab closes.httpOnlycookies: Not accessible via JavaScript. Protected from XSS token theft. Requires CSRF protection (useSameSite: StrictorSameSite: Lax, or implement a CSRF token). More complex to set up but more secure for production web applications.In-memory (React state): The most secure option against XSS because the token is not persisted anywhere. The tradeoff is that the token is lost on page refresh, requiring a refresh token flow or re-authentication.
For most production web applications, httpOnly cookies are the recommended approach. For server-to-server or mobile contexts, the Authorization header with a token stored in secure storage is appropriate.
Token Expiry and Refresh Tokens
Short access token expiry (15 minutes) limits exposure but requires a mechanism to extend sessions without requiring the user to log in repeatedly.
The standard approach is a refresh token: a long-lived token stored in an httpOnly cookie that can be exchanged for a new access token. The access token is short-lived and can be stored less securely. The refresh token is long-lived but only ever travels to the refresh endpoint.
// Refresh endpoint
app.post("/refresh", (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
jwt.verify(refreshToken, REFRESH_SECRET, (err, decoded) => {
if (err) return res.sendStatus(403);
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
SECRET,
{ expiresIn: "15m" }
);
res.json({ token: newAccessToken });
});
});On the client, intercept 401 responses and attempt a refresh before redirecting to login:
async function fetchWithRefresh(endpoint) {
let res = await fetch(`http://localhost:5000${endpoint}`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
credentials: "include",
});
if (res.status === 401) {
// Attempt token refresh
const refreshRes = await fetch("http://localhost:5000/refresh", {
method: "POST",
credentials: "include",
});
if (refreshRes.ok) {
const { token } = await refreshRes.json();
storeAccessToken(token);
// Retry the original request
res = await fetch(`http://localhost:5000${endpoint}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: "include",
});
} else {
window.location.href = "/login";
return;
}
}
return res.json();
}Key Takeaways
JWTs are not encrypted by default. The payload is readable by anyone with the token. Do not store sensitive data in the payload.
Use short access token expiry (15 minutes) and refresh tokens for production applications. Long-lived access tokens are a security liability.
httpOnlycookies are more secure thanlocalStoragefor web applications. JavaScript cannot readhttpOnlycookies, which prevents XSS from stealing tokens.The
ProtectedRoutecomponent should pass the attempted location to the login redirect so users are returned to the right page after authentication.Handle 401 responses explicitly in API calls. Silently failing when a token expires produces confusing application behavior.
Distinguish
TokenExpiredErrorfrom other JWT verification errors on the server. Expired tokens warrant a refresh attempt; invalid tokens warrant a 403 and re-authentication.
Conclusion
JWT authentication in React follows a consistent pattern: the backend issues a signed token on login, the client stores and sends it with requests, and the server verifies it before responding. The implementation details — token storage, expiry, refresh flow, and protected route handling — determine how secure and maintainable the setup is.
The code here is a working foundation. For production, httpOnly cookies, a refresh token flow, and robust 401 handling are the additions that make the authentication layer genuinely secure.
Building JWT auth with a specific framework or backend? Share your setup or a problem you ran into in the comments.




