Accueil / Articles / Mise à jour des tokens Express et Axios, suivi de chaque requête

Mise à jour des tokens Express et Axios, suivi de chaque requête

Créez un flux d’accès et de renouvellement JWT avec un backend Express et un client Axios, puis suivez comment une erreur 401 se transforme en un renouvellement partagé et en une tentative de réessai transparente.

1906 mots

Les jetons d’accès à courte durée expirent au milieu de la session, sans que les utilisateurs ne s’en aperçoivent jamais. Cette démarche guide la création d’une API Express et d’un client Axios, puis suit pas à pas une requête expirée de début à fin :

API request
   ↓
Access token
   ↓
Backend
   ↓
401 Unauthorized
   ↓
Axios response interceptor
   ↓
Refresh token
   ↓
New access token
   ↓
Retry original request
   ↓
Return original response

Pour en savoir plus sur les erreurs 401 simultanées, consultez notre analyse approfondie du mécanisme de mise à jour unique.

Le backend Express

Le jeton de mise à jour est stocké dans un cookie HttpOnly ; le jeton d’accès est conservé dans localStorage par souci de simplicité. CORS autorise l’utilisation des identifiants provenant de l’origine Vite. Ces valeurs sont à des fins démonstratives ; utilisez plutôt des valeurs réelles provenant de l’environnement.

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";

Problèmes de connexion affectant les deux jetons

La démonstration vérifie les identifiants codés en dur, signe un jeton d’accès valable 15 secondes et un jeton de renouvellement valable 7 jours, puis définit le cookie. Supprimez la ligne de texte superflue à la fin de la liste avant de l’exécuter.

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 et routes protégées

authenticate vérifie le token Bearer et renvoie une réponse 401 en cas de problème.

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",
    });
  }
}

Deux routes l’utilisent :

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",
      },
    ],
  });
});

Renouvellement et déconnexion

Le processus de renouvellement vérifie le cookie et renvoie un nouveau jeton d’accès, sans modifier le jeton de renouvellement.

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",
    });
  }
});

La déconnexion efface le cookie ; puis le serveur est démarré.

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");
});

Le client Axios

Le client se trouve dans un seul module :

src/
  api/
    api.ts

withCredentials: true est important : sans cette option, le navigateur n’envoie jamais le cookie de renouvellement.

import axios, {
  AxiosError,
  InternalAxiosRequestConfig,
} from "axios";

const api = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});

L’intercepteur de requête attache le jeton stocké :

api.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const accessToken =
      localStorage.getItem("accessToken");
if (accessToken) {
      config.headers.Authorization =
        `Bearer ${accessToken}`;
    }
    return config;
  }
);

Ainsi, cette appel :

api.get("/profile");

se transforme en :

GET /profile
Authorization: Bearer eyJhbGci...

Rafraîchissement unique pour plusieurs erreurs 401

Lorsque plusieurs requêtes échouent en même temps :

GET /profile       → 401
GET /students      → 401
GET /notifications → 401
GET /dashboard     → 401

il faut éviter un rafraîchissement par échec :

POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh

À la place, tous les échecs doivent partager un seul rafraîchissement :

GET /profile       → 401 ─┐
GET /students      → 401 ─┤
GET /notifications → 401 ─┤
GET /dashboard     → 401 ─┘
                           ↓
                    ONE refresh request
                           ↓
                    new access token
                           ↓
              ┌────────────┼────────────┐
              ↓            ↓            ↓
           retry         retry        retry

Le outil est une promesse au niveau du module :

let refreshPromise: Promise<string> | null = null;

refreshAccessToken() la crée uniquement s’il n’en existe pas et la supprime dans le bloc 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;
}

Le mécanisme de protection effectue le travail :

This is the key:
if (!refreshPromise) {
    refreshPromise = api.post("/auth/refresh");
}

Le premier appelant démarre le rafraîchissement ; les suivants trouvent

refreshPromise !== null

et attendent cette même promesse.

L’intercepteur de réponse

Le module complet ajoute 401 → actualisation → tentative supplémentaire, en sautant /auth/refresh lui-même et en marquant les requêtes avec _retry. Si l’actualisation échoue, il efface le jeton et redirige vers /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;

Connexion et appels aux données

La connexion est gérée dans son propre fichier :

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;
}

Le navigateur conserve la cookie d’actualisation de :

Set-Cookie:
refreshToken=...
HttpOnly

Les scripts ne peuvent pas la lire, par conception. Le module de profil :

import api from "./api";
export async function getProfile() {
  const response = await api.get("/profile");  return response.data;
}

Les composants ne touchent jamais aux jetons :

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;

Suivi d’un jeton expiré

Connectez-vous à

10:00:00

et recevez

accessToken
expires in 15 seconds

À

10:00:20

le composant appelle :

api.get("/profile");

L’intercepteur de requête ajoute le jeton obsolète :

localStorage
     ↓
accessToken
     ↓
Authorization header
GET /profile
Authorization: Bearer OLD_TOKEN

Le serveur s’exécute

jwt.verify(OLD_TOKEN)

et réponses :

401 Unauthorized

L’intercepteur de réponse détecte

error.response.status === 401

et appelle :

await refreshAccessToken();

Cela envoie automatiquement le cookie

POST /auth/refresh
Cookie: refreshToken=...

à cause de :

withCredentials: true

Le serveur vérifie

jwt.verify(refreshToken, REFRESH_SECRET)

des problèmes

NEW_ACCESS_TOKEN

et renvoie :

{
  "accessToken": "NEW_TOKEN"
}

Le client le stocke

localStorage.setItem(
  "accessToken",
  newAccessToken
);

et modifie la requête originale :

originalRequest.headers.Authorization =
  `Bearer ${newAccessToken}`;
return api(originalRequest);

Ainsi

GET /profile
Authorization: Bearer OLD_TOKEN

il est renvoyé en tant que

GET /profile
Authorization: Bearer NEW_TOKEN

et réussit :

200 OK

Concurrence et mécanisme de tentative

Supposons que quatre requêtes soient envoyées lorsque le jeton expire :

Promise.all([
  api.get("/profile"),
  api.get("/students"),
  api.get("/teachers"),
  api.get("/notifications"),
]);

Chacune reçoit

401

Sans une promesse partagée, quatre mises à jour :

profile       → 401 → refresh
students      → 401 → refresh
teachers      → 401 → refresh
notifications → 401 → refresh

Avec

let refreshPromise: Promise<string> | null = null;

elles convergent :

profile
   ↓
401
   ↓
create refreshPromise
   ↓
POST /auth/refresh
            ↑
            │
students ───┤
401         │
            │
teachers ──┤
401         │
            │
notifications
401         │
            │
            ↓
       same Promise
            ↓
       NEW TOKEN

et chaque requête est réessayée après un rafraîchissement :

profile       → retry
students      → retry
teachers      → retry
notifications → retry

_retry arrête les boucles. Si la requête réessayée est toujours rejetée :

GET /profile
     ↓
401
     ↓
refresh
     ↓
new token
     ↓
GET /profile again
     ↓
401

une logique naïve comme

if (status === 401) {
   refresh();
   retry();
}

des cycles infinis :

401
 ↓
refresh
 ↓
retry
 ↓
401
 ↓
refresh
 ↓
retry
 ↓
401
 ↓
refresh
 ↓
...

Définition

originalRequest._retry = true;

et vérification

!originalRequest._retry

permet une seule tentative de réessai par requête.

Un client séparé pour le rafraîchissement

La vérification de l’URL est fragile. Conservez le client principal

const api = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});

et ajoutez-en un sans intercepteurs :

const refreshClient = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});
refreshPromise = refreshClient
  .post("/auth/refresh")
  .then(...)

Le rafraîchissement contourne alors les intercepteurs d’authentification.

La vue d’ensemble

Tout le flux en un seul diagramme :

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

Notes pour la production

  • Utilisez des cookies Secure via HTTPS.
  • Gardez les tokens d’accès en mémoire, et non dans localStorage.
  • Rotater et révoquer les tokens de renouvellement.
  • Lectures complémentaires