Odnowa tokenów Express i Axios, śledzenie żądań pojedynczo
Stwórz proces dostępu i odnowienia tokena JWT przy użyciu backendu Express oraz klienta Axios, a następnie prześledź, jak błąd 401 przekształca się w wspólną operację odnowienia oraz transparentną próbę ponowną.
Tokeny dostępu o krótkim czasie trwania wygasają w trakcie sesji, a użytkownicy nie powinni tego zauważyć. Ten przewodnik tworzy API Express oraz klienta Axios, a następnie śledzi jeden wygasły żądanie od początku do końca:
API request
↓
Access token
↓
Backend
↓
401 Unauthorized
↓
Axios response interceptor
↓
Refresh token
↓
New access token
↓
Retry original request
↓
Return original response
Aby dowiedzieć się więcej na temat jednoczesnych błędów 401, zapoznaj się z naszym artykułem dotyczącym szczegółowego omówienia mechanizmu single-flight refresh.
Tło serwerowe Express
Tokén odnowienia znajduje się w cookie’u HttpOnly; token dostępu jest przechowywany w localStorage ze względu na prostotę. CORS umożliwia użycie danych uwierzytelniających z domeny Vite. Podane wartości są tylko demonstracyjne; w rzeczywistych aplikacjach należy używać prawdziwych wartości z środowiska.
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";
Problemy z logowaniem dotyczą obu tokenów
Demo sprawdza ustalone uprzednio dane logowania, podpisuje token dostępu ważny 15 sekund oraz token odnowienia ważny 7 dni i ustawia ciasteczko. Przed uruchomieniem usuń dodatkowy wiersz tekstu na końcu listy.
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 i chronione trasy
authenticate weryfikuje token Bearer i zwraca błąd 401 w przypadku jakichkolwiek problemów.
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",
});
}
}
Dwie trasy go wykorzystują:
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",
},
],
});
});
Odnowienie i wylogowanie
Funkcja odnowienia weryfikuje ciasteczko i zwraca nowy token dostępu, nie rotując przy tym tokena odnowienia.
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",
});
}
});
Wylogowanie usuwa ciasteczko; następnie uruchamia się serwer.
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");
});
Klient Axios
Klient znajduje się w jednym module:
src/
api/
api.ts
withCredentials: true ma znaczenie: bez niego przeglądarka nigdy nie wysyła ciasteczka odnowienia.
import axios, {
AxiosError,
InternalAxiosRequestConfig,
} from "axios";
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
Interceptor żądań dołącza przechowywany token:
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken =
localStorage.getItem("accessToken");
if (accessToken) {
config.headers.Authorization =
`Bearer ${accessToken}`;
}
return config;
}
);
Zatem ta wywołanie:
api.get("/profile");
wygląda tak:
GET /profile
Authorization: Bearer eyJhbGci...
Odświeżanie raz dla wielu błędów 401
Gdy kilka żądań zawiedzie jednocześnie:
GET /profile → 401
GET /students → 401
GET /notifications → 401
GET /dashboard → 401
trzeba unikać odświeżania po każdym niepowodzeniu:
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
Zamiast tego wszystkie niepowodzenia powinny korzystać z jednego odświeżenia:
GET /profile → 401 ─┐
GET /students → 401 ─┤
GET /notifications → 401 ─┤
GET /dashboard → 401 ─┘
↓
ONE refresh request
↓
new access token
↓
┌────────────┼────────────┐
↓ ↓ ↓
retry retry retry
Narzędzie to obietnica na poziomie modułu:
let refreshPromise: Promise<string> | null = null;
refreshAccessToken() tworzy ją tylko wtedy, gdy nie istnieje, i usuwa ją w bloku 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;
}
Guard wykonuje tę pracę:
This is the key:
if (!refreshPromise) {
refreshPromise = api.post("/auth/refresh");
}
Pierwszy wywołujący uruchamia odświeżanie; późniejsze znajdują
refreshPromise !== null
i czekają na tę samą obietnicę.
Interceptor odpowiedzi
Pełny moduł dodaje sekwencję 401 → odświeżenie → ponowna próba, pomijając sam plik /auth/refresh i oznaczając żądania tagiem _retry. Jeśli odświeżenie się nie powiedzie, usuwa token i przekierowuje na /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;
Logowanie i wywołania danych
Logowanie znajduje się w osobnym pliku:
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;
}
Przeglądarka przechowuje ciasteczko odświeżenia z:
Set-Cookie:
refreshToken=...
HttpOnly
Zgodnie z projektem skrypty nie mogą go odczytać. Moduł profilu:
import api from "./api";
export async function getProfile() {
const response = await api.get("/profile"); return response.data;
}
Komponenty nigdy nie dotykają tokenów:
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;
Śledzenie wygasłego tokena
Zaloguj się na
10:00:00
i otrzymaj
accessToken
expires in 15 seconds
W momencie
10:00:20
komponent wywołuje:
api.get("/profile");
Interceptor żądań dodaje przestarzały token:
localStorage
↓
accessToken
↓
Authorization header
GET /profile
Authorization: Bearer OLD_TOKEN
Server działa
jwt.verify(OLD_TOKEN)
i odpowiedzi:
401 Unauthorized
Interceptor odpowiedzi wykrywa
error.response.status === 401
i wywołuje:
await refreshAccessToken();
To automatycznie wysyła plik cookie
POST /auth/refresh
Cookie: refreshToken=...
z powodu:
withCredentials: true
Serwer sprawdza
jwt.verify(refreshToken, REFRESH_SECRET)
problemy
NEW_ACCESS_TOKEN
i zwraca:
{
"accessToken": "NEW_TOKEN"
}
Klient go przechowuje
localStorage.setItem(
"accessToken",
newAccessToken
);
i modyfikuje oryginalną prośbę:
originalRequest.headers.Authorization =
`Bearer ${newAccessToken}`;
return api(originalRequest);
Zatem
GET /profile
Authorization: Bearer OLD_TOKEN
jest wysyłany ponownie jako
GET /profile
Authorization: Bearer NEW_TOKEN
i odnosi sukces:
200 OK
Konkurencja i mechanizm ponawiania prób
Załóżmy, że po wygaśnięciu tokena wysyłane są cztery prośby:
Promise.all([
api.get("/profile"),
api.get("/students"),
api.get("/teachers"),
api.get("/notifications"),
]);
Każda otrzymuje
401
Bez wspólnego obietnicy – cztery odświeżenia:
profile → 401 → refresh
students → 401 → refresh
teachers → 401 → refresh
notifications → 401 → refresh
Z
let refreshPromise: Promise<string> | null = null;
one się łączą:
profile
↓
401
↓
create refreshPromise
↓
POST /auth/refresh
↑
│
students ───┤
401 │
│
teachers ──┤
401 │
│
notifications
401 │
│
↓
same Promise
↓
NEW TOKEN
i każda prośba jest ponawiana po jednym odświeżeniu:
profile → retry
students → retry
teachers → retry
notifications → retry
_retry zatrzymuje pętle. Jeśli ponowiona prośba nadal zostanie odrzucona:
GET /profile
↓
401
↓
refresh
↓
new token
↓
GET /profile again
↓
401
naïwna logika taka jak
if (status === 401) {
refresh();
retry();
}
cykluje wiecznie:
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
...
Ustawianie
originalRequest._retry = true;
i sprawdzanie
!originalRequest._retry
zezwala na jedną próbę ponowną na prośbę.
Odrębny klient do odświeżania
Weryfikacja URL jest krucha. Zachowaj głównego klienta
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
i dodaj jednego bez interceptorów:
const refreshClient = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
refreshPromise = refreshClient
.post("/auth/refresh")
.then(...)
Odświeżenie omija wtedy interceptorzy autoryzacji.
Pełny obraz sytuacji
Cały proces w jednym diagramie:
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
Uwagi dotyczące produkcji
- Używaj plików cookie
Secureprzez HTTPS. - Zachowuj tokeny dostępu w pamięci, a nie w
localStorage.
Literatura pokrewna
- Dlaczego konkurencyjne błędy 401 wylogowują użytkowników i rozwiązanie z jednym tokenem odświeżania — Jak równoległe żądania i rotacja tokenów odświeżania powodują niespodziewane wylogowania, oraz jak jeden wspólny token odświeżania w intercepcie Axios temu zapobiega.
- Strategia tokenów odświeżania dla systemów autoryzacji Node.js — Dowiedz się, jak projektować, zmieniać, anulować i bezpiecznie przechowywać tokeny odświeżania w Node.js, aby kradzieże tokenów i wylogowania zachowywały się zgodnie z oczekiwaniami.