Home / Articles / Express and Axios Token Refresh, Traced Request by Request

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.

1906 words

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 Secure cookies over HTTPS.
  • Keep access tokens in memory, not localStorage.
  • Rotate and revoke refresh tokens.