Actualización de tokens de Express y Axios, seguimiento de solicitudes por solicitud
Cree un flujo de acceso y actualización con JWT utilizando un backend de Express y un cliente Axios, y luego trace cómo un error 401 se convierte en una actualización compartida y en un intento de reintentar de forma transparente.
Los tokens de acceso de corta duración caducan a mitad de sesión, y los usuarios nunca deberían darse cuenta. Esta guía crea una API Express y un cliente Axios, para luego rastrear una solicitud vencida de principio a fin:
API request
↓
Access token
↓
Backend
↓
401 Unauthorized
↓
Axios response interceptor
↓
Refresh token
↓
New access token
↓
Retry original request
↓
Return original response
Para obtener más información sobre los errores 401 concurrentes, consulte nuestro análisis en profundidad sobre el mecanismo de actualización única.
El backend Express
El token de actualización se almacena en una cookie HttpOnly; el token de acceso se guarda en localStorage por simplicidad. CORS permite el uso de credenciales desde el origen Vite. Los valores utilizados son solo para demostración; cargue valores reales desde el entorno.
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";
Problemas de inicio de sesión con ambos tokens
La demostración verifica las credenciales codificadas, firma un token de acceso de 15 segundos y un token de actualización de 7 días, y establece la cookie. Elimine la línea de texto adicional al final de la lista antes de ejecutarla.
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 y rutas protegidas
authenticate verifica el token Bearer y responde con 401 ante cualquier problema.
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",
});
}
}
Dos rutas lo utilizan:
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",
},
],
});
});
Actualización y cierre de sesión
La función de actualización verifica la cookie y devuelve un nuevo token de acceso, sin rotar el token de actualización.
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",
});
}
});
Al cerrar la sesión se elimina la cookie; luego se inicia el servidor.
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");
});
El cliente Axios
El cliente se encuentra en un módulo:
src/
api/
api.ts
withCredentials: true es importante: sin él el navegador nunca envía la cookie de actualización.
import axios, {
AxiosError,
InternalAxiosRequestConfig,
} from "axios";
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
El interceptador de solicitudes adjunta el token almacenado:
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken =
localStorage.getItem("accessToken");
if (accessToken) {
config.headers.Authorization =
`Bearer ${accessToken}`;
}
return config;
}
);
Así que esta llamada:
api.get("/profile");
se envía como:
GET /profile
Authorization: Bearer eyJhbGci...
Actualización una sola vez para varios errores 401
Cuando varias solicitudes fallan al mismo tiempo:
GET /profile → 401
GET /students → 401
GET /notifications → 401
GET /dashboard → 401
debe evitarse realizar una actualización por cada fallo:
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
En su lugar, todos los fallos deben compartir una sola actualización:
GET /profile → 401 ─┐
GET /students → 401 ─┤
GET /notifications → 401 ─┤
GET /dashboard → 401 ─┘
↓
ONE refresh request
↓
new access token
↓
┌────────────┼────────────┐
↓ ↓ ↓
retry retry retry
La herramienta es una promesa a nivel de módulo:
let refreshPromise: Promise<string> | null = null;
refreshAccessToken() la crea solo si no existe y la limpia en 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;
}
El guardián se encarga del trabajo:
This is the key:
if (!refreshPromise) {
refreshPromise = api.post("/auth/refresh");
}
El primer llamante inicia la actualización; los siguientes encuentran
refreshPromise !== null
y esperan esa misma promesa.
El interceptador de respuestas
El módulo completo agrega 401 → actualizar → intentar de nuevo, omitiendo el propio /auth/refresh y marcando las solicitudes con _retry. Si la actualización falla, elimina el token y redirige a /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;
Inicio de sesión y llamadas a datos
El inicio de sesión está en su propio archivo:
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;
}
El navegador mantiene la cookie de actualización desde:
Set-Cookie:
refreshToken=...
HttpOnly
Por diseño, los scripts no pueden leerla. El módulo de perfil:
import api from "./api";
export async function getProfile() {
const response = await api.get("/profile"); return response.data;
}
Los componentes nunca modifican los 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;
Rastreo de un token vencido
Inicie sesión en
10:00:00
y reciba
accessToken
expires in 15 seconds
En
10:00:20
el componente llama a:
api.get("/profile");
El interceptor de solicitudes agrega el token obsoleto:
localStorage
↓
accessToken
↓
Authorization header
GET /profile
Authorization: Bearer OLD_TOKEN
El servidor se ejecuta
jwt.verify(OLD_TOKEN)
y respuestas:
401 Unauthorized
El interceptor de respuestas detecta
error.response.status === 401
y llama a:
await refreshAccessToken();
Eso envía la cookie automáticamente
POST /auth/refresh
Cookie: refreshToken=...
debido a:
withCredentials: true
El servidor verifica
jwt.verify(refreshToken, REFRESH_SECRET)
problemas
NEW_ACCESS_TOKEN
y devuelve:
{
"accessToken": "NEW_TOKEN"
}
El cliente la almacena
localStorage.setItem(
"accessToken",
newAccessToken
);
y modifica la solicitud original:
originalRequest.headers.Authorization =
`Bearer ${newAccessToken}`;
return api(originalRequest);
Así que
GET /profile
Authorization: Bearer OLD_TOKEN
se vuelve a enviar como
GET /profile
Authorization: Bearer NEW_TOKEN
y tiene éxito:
200 OK
Concurrencia y la protección de reintentos
Supongamos que se envían cuatro solicitudes cuando el token expira:
Promise.all([
api.get("/profile"),
api.get("/students"),
api.get("/teachers"),
api.get("/notifications"),
]);
Cada una recibe
401
Sin una promesa compartida, cuatro actualizaciones:
profile → 401 → refresh
students → 401 → refresh
teachers → 401 → refresh
notifications → 401 → refresh
Con
let refreshPromise: Promise<string> | null = null;
ellos convergen:
profile
↓
401
↓
create refreshPromise
↓
POST /auth/refresh
↑
│
students ───┤
401 │
│
teachers ──┤
401 │
│
notifications
401 │
│
↓
same Promise
↓
NEW TOKEN
y cada solicitud se vuelve a intentar después de un refresco:
profile → retry
students → retry
teachers → retry
notifications → retry
_retry detiene los bucles. Si la solicitud reintentada sigue siendo rechazada:
GET /profile
↓
401
↓
refresh
↓
new token
↓
GET /profile again
↓
401
una lógica ingenua como
if (status === 401) {
refresh();
retry();
}
gira eternamente:
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
...
Establecer
originalRequest._retry = true;
y verificar
!originalRequest._retry
permite un solo intento por solicitud.
Un cliente separado para el refresco
La verificación de la URL es frágil. Mantener el cliente principal
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
y agregar uno sin interceptores:
const refreshClient = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
refreshPromise = refreshClient
.post("/auth/refresh")
.then(...)
El refresco luego omite los interceptores de autenticación.
La imagen completa
Todo el flujo en un único diagrama:
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
Notas para producción
- Use cookies
Securea través de HTTPS. - Mantenga los tokens de acceso en memoria, no en
localStorage.
Lecturas relacionadas
- Por qué los 401s concurrentes expulsan a los usuarios, y la solución de actualización única — Cómo las solicitudes paralelas y la rotación de tokens de actualización generan cierres inesperados de sesión, y cómo una promesa de actualización compartida en un interceptor de Axios lo previene.
- Estrategia de token de actualización para sistemas de autenticación en Node.js — Aprenda cómo diseñar, rotar, revocar y almacenar de forma segura los tokens de actualización en Node.js para que el robo de tokens y los cierres de sesión se comporten tal como se espera.