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.
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
Securevia HTTPS. - Gardez les tokens d’accès en mémoire, et non dans
localStorage.
Lectures complémentaires
- Pourquoi les 401 concurrents déconnectent les utilisateurs, et la solution par un renouvellement unique — Comment les requêtes parallèles et la rotation des tokens de renouvellement entraînent des déconnexions inattendues, et comment une promesse de renouvellement partagée dans un intercepteur Axios empêche cela.
- Stratégie de token de renouvellement pour les systèmes d’authentification Node.js — Apprenez à concevoir, rotater, révoquer et stocker de manière sécurisée les tokens de renouvellement dans Node.js afin que le vol de tokens et les déconnexions se comportent comme prévu.