Startseite / Artikel / Token-Aufladung für Express und Axios, nachgeordnete Anfragen nacheinander verfolgen

Token-Aufladung für Express und Axios, nachgeordnete Anfragen nacheinander verfolgen

Erstellen Sie einen JWT-Zugriffs- und Erneuerungsfluss mit einem Express-Backend und einem Axios-Klienten, und verfolgen Sie anschließend, wie ein 401-Fehler in einen gemeinsamen Erneuerungsprozess sowie einen transparenten Neversuch umgewandelt wird.

1906 Wörter

Kurzlebige Zugriffstoken verfallen mitten in der Sitzung, und die Benutzer sollten es niemals bemerken. In dieser Anleitung wird eine Express-API sowie ein Axios-Client erstellt, anschließend wird eine abgelaufene Anfrage von Anfang bis Ende nachverfolgt:

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

Weitere Informationen zu gleichzeitigen 401-Fehlern finden Sie in unserem ausführlichen Artikel zum Single-Flight-Refresh.

Der Express-Backend

Das Erneuerungstoken befindet sich in einem HttpOnly-Cookie; das Zugriffstoken liegt aus Gründen der Einfachheit in localStorage. CORS ermöglicht das Übermitteln von Anmeldeinformationen aus der Vite-Quelle. Die genannten Werte dienen nur zur Demonstration; laden Sie echte Werte aus der Umgebungsvariablen.

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

Einlog-Probleme beider Token

Die Demo überprüft hardcodierte Anmeldeinformationen, signiert ein 15-sekündiges Zugriffstoken sowie ein 7-tägiges Erneuerungstoken und setzt das Cookie. Löschen Sie die überflüssige Textzeile am Ende der Auflistung, bevor Sie sie ausführen.

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 und geschützte Routen

authenticate überprüft das Bearer-Token und gibt bei jedem Problem 401 zurück.

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

Zwei Routen verwenden es:

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

Erneuern und Abmelden

Das Erneuern überprüft das Cookie und gibt ein neues Zugriffstoken aus, ohne das Erneuerungstoken zu rotieren.

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

Beim Abmelden wird das Cookie gelöscht; danach wird der Server gestartet.

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

Der Axios-Client

Der Client befindet sich in einem Modul:

src/
  api/
    api.ts

withCredentials: true ist wichtig: Ohne diesen Wert sendet der Browser das Erneuerungscookie niemals.

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

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

Der Anfragen-Interceptor fügt das gespeicherte Token hinzu:

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

Also diese Aufruf:

api.get("/profile");

wird wie folgt ausgesendet:

GET /profile
Authorization: Bearer eyJhbGci...

Auffrischen einmal für mehrere 401-Fehler

Wenn mehrere Anfragen gleichzeitig fehlschlagen:

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

muss man ein Auffrischen pro Fehler vermeiden:

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

Stattdessen sollten alle Fehler ein gemeinsames Auffrischen teilen:

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

Das Tool ist eine Promise auf Modulebene:

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

refreshAccessToken() erstellt sie nur, wenn keine vorhanden ist, und löscht sie 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;
}

Der Guard übernimmt die Arbeit:

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

Der erste Aufrufer startet das Auffrischen; spätere finden

refreshPromise !== null

und warten auf dieselbe Promise.

Der Antwort-Interceptor

Das vollständige Modul fügt 401 → aktualisieren → erneut versuchen hinzu, überspringt dabei /auth/refresh selbst und markiert Anfragen mit _retry. Wenn die Aktualisierung fehlschlägt, wird das Token gelöscht und es wird auf /login umgeleitet.

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;

Anmelden und Datenaufrufe

Die Anmeldung befindet sich in einer eigenen Datei:

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

Der Browser behält das Aktualisierungs-Cookie von:

Set-Cookie:
refreshToken=...
HttpOnly

Scripts können es aus Designgründen nicht lesen. Das Profilmodul:

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

Komponenten greifen niemals auf Tokens zu:

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;

Aufspüren eines abgelaufenen Tokens

Melden Sie sich bei

10:00:00

an und erhalten

accessToken
expires in 15 seconds

Zu diesem Zeitpunkt

10:00:20

ruft die Komponente auf:

api.get("/profile");

Der Anfragen-Interceptor fügt das veraltete Token hinzu:

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

Der Server läuft

jwt.verify(OLD_TOKEN)

und Antworten:

401 Unauthorized

Der Antwort-Interceptor erkennt

error.response.status === 401

und ruft an:

await refreshAccessToken();

Dadurch wird die Cookie automatisch gesendet

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

wegen:

withCredentials: true

Der Server überprüft

jwt.verify(refreshToken, REFRESH_SECRET)

Probleme

NEW_ACCESS_TOKEN

und gibt zurück:

{
  "accessToken": "NEW_TOKEN"
}

Der Client speichert es

localStorage.setItem(
  "accessToken",
  newAccessToken
);

und patcht die ursprüngliche Anfrage:

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

So

GET /profile
Authorization: Bearer OLD_TOKEN

wird es erneut als

GET /profile
Authorization: Bearer NEW_TOKEN

und erfolgreich gesendet:

200 OK

Konkurrenz und der Wiederholungs-Schutz

Nehmen wir an, vier Anfragen werden ausgelöst, sobald das Token abläuft:

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

Jede erhält

401

Ohne eine gemeinsame Promise, vier Aktualisierungen:

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

Mit

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

konvergieren sie:

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

und jede Anfrage wird nach einem Neuladen erneut versucht:

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

_retry beendet Schleifen. Wenn die erneut versuchte Anfrage weiterhin abgelehnt wird:

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

eine naive Logik wie

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

führt endlos weiter:

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

Einstellen

originalRequest._retry = true;

und überprüfen

!originalRequest._retry

erlaubt eine Wiederholung pro Anfrage.

Ein separater Client für das Neuladen

Die URL-Überprüfung ist anfällig. Behalten Sie den Hauptclient bei

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

und fügen Sie einen ohne Interceptor hinzu:

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

Das Neuladen umgeht anschließend die Authentifizierungs-Interceptor.

Das Gesamtbild

Der gesamte Ablauf in einem Diagramm:

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

Produktionshinweise

  • Verwenden Sie Secure-Cookies über HTTPS.
  • Halten Sie Zugriffstoken im Speicher, nicht in localStorage.
  • Rotieren und widerrufen Sie Refresh-Tokens.
  • Verwandte Artikel