Inicio / Artículos / Actualización de tokens de Express y Axios, seguimiento de solicitudes por solicitud

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.

1906 palabras

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 Secure a través de HTTPS.
  • Mantenga los tokens de acceso en memoria, no en localStorage.
  • Rotar y revocar tokens de actualización.
  • Lecturas relacionadas