This article is published in English.
Express and Axios Token Refresh, Traced Request by Request
Build a JWT access and refresh flow with an Express backend and an Axios client, then trace how a 401 becomes one shared refresh and a transparent retry.
Short-lived access tokens expire mid-session, and users should never notice. This walkthrough builds an Express API and an Axios client, then traces one expired request end to end:
API request
↓
Access token
↓
Backend
↓
401 Unauthorized
↓
Axios response interceptor
↓
Refresh token
↓
New access token
↓
Retry original request
↓
Return original response
For more on concurrent 401s, see our single-flight refresh deep dive.
The Express backend
The refresh token lives in an HttpOnly cookie; the access token sits in localStorage for simplicity. CORS allows credentials from the Vite origin. The secrets are demo values; load real ones from the environment.
import express from "express";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import cors from "cors";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: "http://localhost:5173",
credentials: true,
})
);
const ACCESS_SECRET = "access-secret";
const REFRESH_SECRET = "refresh-secret";
Login issues both tokens
The demo checks hardcoded credentials, signs a 15-second access token and a 7-day refresh token, and sets the cookie. Delete the stray prose line at the end of the listing before running it.
app.post("/auth/login", (req, res) => {
const { email, password } = req.body;
if (
email !== "asif@gmail.com" ||
password !== "asif@123"
) {
return res.status(401).json({
message: "Invalid email or password",
});
}
const user = {
userId: 1,
email,
};
const accessToken = jwt.sign(
user,
ACCESS_SECRET,
{
expiresIn: "15s",
}
);
const refreshToken = jwt.sign(
{
userId: user.userId,
},
REFRESH_SECRET,
{
expiresIn: "7d",
}
);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: false, // true in production with HTTPS
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({
message: "Login successful",
accessToken,
user,
});
});
access token expires after only 15 seconds.
Middleware and protected routes
authenticate verifies the Bearer token and answers 401 on any problem.
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
message: "Access token missing",
});
}
const [type, token] = authHeader.split(" ");
if (type !== "Bearer" || !token) {
return res.status(401).json({
message: "Invalid authorization header",
});
}
try {
const decoded = jwt.verify(
token,
ACCESS_SECRET
);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
message: "Access token expired or invalid",
});
}
}
Two routes use it:
app.get("/profile", authenticate, (req, res) => {
return res.json({
message: "Profile fetched successfully",
user: req.user,
});
});
app.get("/students", authenticate, (req, res) => {
return res.json({
students: [
{
id: 1,
name: "Rahul",
},
{
id: 2,
name: "Aman",
},
],
});
});
Refresh and logout
Refresh verifies the cookie and returns a new access token, without rotating the refresh token.
app.post("/auth/refresh", (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({
message: "Refresh token missing",
});
}
try {
const decoded = jwt.verify(
refreshToken,
REFRESH_SECRET
);
const user = {
userId: decoded.userId,
email: "asif@gmail.com",
};
const newAccessToken = jwt.sign(
user,
ACCESS_SECRET,
{
expiresIn: "15s",
}
);
return res.json({
accessToken: newAccessToken,
});
} catch (error) {
return res.status(401).json({
message: "Refresh token expired or invalid",
});
}
});
Logout clears the cookie; then start the server.
app.post("/auth/logout", (req, res) => {
res.clearCookie("refreshToken");
return res.json({
message: "Logged out successfully",
});
});
app.listen(5000, () => {
console.log("Server running on http://localhost:5000");
});
The Axios client
The client lives in one module:
src/
api/
api.ts
withCredentials: true matters: without it the browser never sends the refresh cookie.
import axios, {
AxiosError,
InternalAxiosRequestConfig,
} from "axios";
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
The request interceptor attaches the stored token:
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken =
localStorage.getItem("accessToken");
if (accessToken) {
config.headers.Authorization =
`Bearer ${accessToken}`;
}
return config;
}
);
So this call:
api.get("/profile");
goes out as:
GET /profile
Authorization: Bearer eyJhbGci...
Refreshing once for many 401s
When several requests fail together:
GET /profile → 401
GET /students → 401
GET /notifications → 401
GET /dashboard → 401
you must avoid one refresh per failure:
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
Instead, all failures should share one refresh:
GET /profile → 401 ─┐
GET /students → 401 ─┤
GET /notifications → 401 ─┤
GET /dashboard → 401 ─┘
↓
ONE refresh request
↓
new access token
↓
┌────────────┼────────────┐
↓ ↓ ↓
retry retry retry
The tool is a module-level promise:
let refreshPromise: Promise<string> | null = null;
refreshAccessToken() creates it only if none exists and clears it in finally.
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
if (!refreshPromise) {
refreshPromise = api
.post("/auth/refresh")
.then((response) => {
const newAccessToken =
response.data.accessToken;
localStorage.setItem(
"accessToken",
newAccessToken
);
return newAccessToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
The guard does the work:
This is the key:
if (!refreshPromise) {
refreshPromise = api.post("/auth/refresh");
}
The first caller starts the refresh; later ones find
refreshPromise !== null
and await that same promise.
The response interceptor
The full module adds 401 → refresh → retry, skipping /auth/refresh itself and marking requests with _retry. If refresh fails, it clears the token and redirects to /login.
import axios, {
AxiosError,
InternalAxiosRequestConfig,
} from "axios";
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
// =====================================================
// REFRESH STATE
// =====================================================
let refreshPromise: Promise<string> | null = null;
// =====================================================
// REFRESH ACCESS TOKEN
// =====================================================
async function refreshAccessToken(): Promise<string> {
/*
* If another request is already refreshing the token,
* wait for that same request.
*/
if (!refreshPromise) {
refreshPromise = api
.post("/auth/refresh")
.then((response) => {
const newAccessToken =
response.data.accessToken;
localStorage.setItem(
"accessToken",
newAccessToken
);
return newAccessToken;
})
.finally(() => {
/*
* Allow a future refresh after this one finishes.
*/
refreshPromise = null;
});
}
return refreshPromise;
}
// =====================================================
// REQUEST INTERCEPTOR
// =====================================================
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken =
localStorage.getItem("accessToken");
if (accessToken) {
config.headers.Authorization =
`Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// =====================================================
// RESPONSE INTERCEPTOR
// =====================================================
api.interceptors.response.use(
// -----------------------------------------------
// SUCCESS
// -----------------------------------------------
(response) => {
return response;
},
// -----------------------------------------------
// ERROR
// -----------------------------------------------
async (error: AxiosError) => {
const originalRequest =
error.config as
| (InternalAxiosRequestConfig & {
_retry?: boolean;
})
| undefined;
if (!originalRequest) {
return Promise.reject(error);
}
const isUnauthorized =
error.response?.status === 401;
const isRefreshRequest =
originalRequest.url === "/auth/refresh";
/*
* Only refresh once for a request.
*/
if (
isUnauthorized &&
!originalRequest._retry &&
!isRefreshRequest
) {
originalRequest._retry = true;
try {
// Get new access token
const newAccessToken =
await refreshAccessToken();
// Attach new token
originalRequest.headers.Authorization =
`Bearer ${newAccessToken}`;
// Retry original request
return api(originalRequest);
} catch (refreshError) {
/*
* Refresh token itself failed.
* User needs to login again.
*/
localStorage.removeItem("accessToken");
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;
Login and data calls
Login lives in its own file:
src/api/auth.ts
import api from "./api";
export async function login(
email: string,
password: string
) {
const response = await api.post("/auth/login", {
email,
password,
});
const { accessToken, user } =
response.data;
localStorage.setItem(
"accessToken",
accessToken
);
return user;
}
The browser keeps the refresh cookie from:
Set-Cookie:
refreshToken=...
HttpOnly
Scripts cannot read it, by design. The profile module:
import api from "./api";
export async function getProfile() {
const response = await api.get("/profile"); return response.data;
}
Components never touch tokens:
import { useEffect } from "react";
import { getProfile } from "./api/profile";
function Profile() {
useEffect(() => {
getProfile()
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error(error);
});
}, []);
return <div>Profile</div>;
}
export default Profile;
Tracing an expired token
Log in at
10:00:00
and receive
accessToken
expires in 15 seconds
At
10:00:20
the component calls:
api.get("/profile");
The request interceptor adds the stale token:
localStorage
↓
accessToken
↓
Authorization header
GET /profile
Authorization: Bearer OLD_TOKEN
The server runs
jwt.verify(OLD_TOKEN)
and answers:
401 Unauthorized
The response interceptor detects
error.response.status === 401
and calls:
await refreshAccessToken();
That sends the cookie automatically
POST /auth/refresh
Cookie: refreshToken=...
because of:
withCredentials: true
The server checks
jwt.verify(refreshToken, REFRESH_SECRET)
issues
NEW_ACCESS_TOKEN
and returns:
{
"accessToken": "NEW_TOKEN"
}
The client stores it
localStorage.setItem(
"accessToken",
newAccessToken
);
and patches the original request:
originalRequest.headers.Authorization =
`Bearer ${newAccessToken}`;
return api(originalRequest);
So
GET /profile
Authorization: Bearer OLD_TOKEN
is resent as
GET /profile
Authorization: Bearer NEW_TOKEN
and succeeds:
200 OK
Concurrency and the retry guard
Suppose four requests fire as the token expires:
Promise.all([
api.get("/profile"),
api.get("/students"),
api.get("/teachers"),
api.get("/notifications"),
]);
Each gets
401
Without a shared promise, four refreshes:
profile → 401 → refresh
students → 401 → refresh
teachers → 401 → refresh
notifications → 401 → refresh
With
let refreshPromise: Promise<string> | null = null;
they converge:
profile
↓
401
↓
create refreshPromise
↓
POST /auth/refresh
↑
│
students ───┤
401 │
│
teachers ──┤
401 │
│
notifications
401 │
│
↓
same Promise
↓
NEW TOKEN
and every request retries after one refresh:
profile → retry
students → retry
teachers → retry
notifications → retry
_retry stops loops. If the retried request is still rejected:
GET /profile
↓
401
↓
refresh
↓
new token
↓
GET /profile again
↓
401
naive logic like
if (status === 401) {
refresh();
retry();
}
cycles forever:
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
...
Setting
originalRequest._retry = true;
and checking
!originalRequest._retry
allows one retry per request.
A separate client for refresh
The URL check is fragile. Keep the main client
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
and add one without interceptors:
const refreshClient = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
refreshPromise = refreshClient
.post("/auth/refresh")
.then(...)
Refresh then bypasses the auth interceptors.
The complete picture
The whole flow in one diagram:
React
│
│ api.get()
↓
┌─────────────────┐
│ Request │
│ Interceptor │
│ │
│ Get accessToken │
│ Add Bearer │
└────────┬────────┘
│
↓
Backend
│
┌─────┴─────┐
│ │
200 401
│ │
↓ ↓
return Response
Interceptor
│
↓
Is it 401?
│
YES
↓
Already refreshing?
/ \
YES NO
│ │
↓ ↓
WAIT /refresh
│ │
└──────┬──────┘
↓
New access token
│
↓
Retry original request
│
↓
Backend
│
↓
200
│
↓
React
Production notes
- Use
Securecookies over HTTPS. - Keep access tokens in memory, not
localStorage. - Rotate and revoke refresh tokens.