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.
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.