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.
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.
Verwandte Artikel
- Warum konkurrierende 401-Fehler Benutzer ausloggen – und die Lösung durch ein einziges Refresh-Token — Wie parallele Anfragen und die Rotation von Refresh-Tokens zu unerwarteten Ausloggen führen können, sowie wie eine gemeinsame Refresh-Promesse in einem Axios-Interceptor dies verhindert.
- Refresh-Token-Strategie für Node.js-Authentifizierungssysteme — Erfahren Sie, wie Sie Refresh-Tokens in Node.js entwerfen, rotieren, widerrufen und sicher speichern, damit Diebstahl von Tokens sowie Ausloggen tatsächlich wie erwartet ablaufen.