Startseite / Artikel

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.

Verlag: REACTAPP.TOP8 Min. Lesezeit1906 Wörter

Quellen: AXIOS INTERCEPTOR

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.