Обновление токенов Express и Axios, отслеживание запросов по отдельности
Создайте процесс доступа и обновления JWT с использованием бэкенда Express и клиента Axios, затем проследите, как ошибка 401 превращается в единое обновление и прозрачную попытку повтора.
Источники: AXIOS INTERCEPTOR
Краткосрочные токены доступа истекают в процессе сессии, и пользователи не должны этого замечать. В данном примере создаётся API на Express и клиент на Axios, после чего отслеживается процесс одного запроса с истекшим токеном от начала до конца:
API request
↓
Access token
↓
Backend
↓
401 Unauthorized
↓
Axios response interceptor
↓
Refresh token
↓
New access token
↓
Retry original request
↓
Return original response
Чтобы узнать больше о одновременных ситуациях 401, ознакомьтесь с нашей статьёй подробным разбором механизма обновления токенов.
Бэкенд на Express
Токен обновления хранится в HttpOnly cookie; токен доступа для простоты хранится в localStorage. Механизм CORS разрешает передачу учетных данных с исходного сервера Vite. Указанные здесь значения являются демонстрационными; в реальных проектах используются настоящие значения из среды разработки.
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";
Проблемы с входом и обоими токенами
В демо-версии проверяются жестко заданные учетные данные, генерируется токен доступа сроком на 15 секунд и токен обновления сроком на 7 дней, а также устанавливается куки. Перед запуском удалите лишнюю строку текста в конце описания.
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.
Промежуточные компоненты и защищенные маршруты
authenticate проверяет токен Bearer и в случае проблем возвращает код 401.
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",
});
}
}
Им пользуются два маршрута:
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",
},
],
});
});
Обновление токенов и выход из аккаунта
Функция обновления проверяет куки и возвращает новый токен доступа, не меняя токен обновления.
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",
});
}
});
При выходе из аккаунта удаляются куки; затем запускается сервер.
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");
});
Клиент Axios
Клиент находится в одном модуле:
src/
api/
api.ts
Параметр withCredentials: true имеет значение: без него браузер никогда не отправляет куки обновления.
import axios, {
AxiosError,
InternalAxiosRequestConfig,
} from "axios";
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
Интерцептор запросов присоединяет сохраненный токен:
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken =
localStorage.getItem("accessToken");
if (accessToken) {
config.headers.Authorization =
`Bearer ${accessToken}`;
}
return config;
}
);
Таким образом этот вызов:
api.get("/profile");
преобразуется в:
GET /profile
Authorization: Bearer eyJhbGci...
Обновление один раз для нескольких ошибок 401
Когда несколько запросов сбиваются одновременно:
GET /profile → 401
GET /students → 401
GET /notifications → 401
GET /dashboard → 401
необходимо избегать обновления после каждой ошибки:
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
Вместо этого все ошибки должны использовать одно обновление:
GET /profile → 401 ─┐
GET /students → 401 ─┤
GET /notifications → 401 ─┤
GET /dashboard → 401 ─┘
↓
ONE refresh request
↓
new access token
↓
┌────────────┼────────────┐
↓ ↓ ↓
retry retry retry
Инструмент представляет собой обещание на уровне модуля:
let refreshPromise: Promise<string> | null = null;
refreshAccessToken() создает его только в случае отсутствия и очищает его в блоке 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;
}
Защитный механизм выполняет необходимую работу:
This is the key:
if (!refreshPromise) {
refreshPromise = api.post("/auth/refresh");
}
Первый вызовчик запускает процесс обновления; последующие вызовы находят
refreshPromise !== null
и ожидают то же самое обещание.
Интерцептор ответов
Полная версия модуля добавляет логику 401 → обновление → повторная попытка, пропуская саму ссылку /auth/refresh и помечая запросы атрибутом _retry. Если обновление не удается, модуль аннулирует токен и перенаправляет пользователя на страницу /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;
Вход в систему и запросы к данным
Логика входа находится в отдельном файле:
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;
}
Браузер сохраняет куки, связанные с обновлением токена:
Set-Cookie:
refreshToken=...
HttpOnly
По замыслу скрипты не могут читать эти куки. Модуль профиля:
import api from "./api";
export async function getProfile() {
const response = await api.get("/profile"); return response.data;
}
Компоненты никогда не взаимодействуют с токенами:
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;
Отслеживание истекшего токена
Войдите на
10:00:00
и получите
accessToken
expires in 15 seconds
В момент
10:00:20
компонент выполняет следующие действия:
api.get("/profile");
Интерцептор запросов добавляет устаревший токен:
localStorage
↓
accessToken
↓
Authorization header
GET /profile
Authorization: Bearer OLD_TOKEN
Сервер работает
jwt.verify(OLD_TOKEN)
и ответы:
401 Unauthorized
Интерцептор ответов обнаруживает
error.response.status === 401
и вызывает:
await refreshAccessToken();
Это автоматически отправляет куки
POST /auth/refresh
Cookie: refreshToken=...
потому что:
withCredentials: true
Сервер проверяет
jwt.verify(refreshToken, REFRESH_SECRET)
проблемы
NEW_ACCESS_TOKEN
и возвращает:
{
"accessToken": "NEW_TOKEN"
}
Клиент хранит их
localStorage.setItem(
"accessToken",
newAccessToken
);
и модифицирует исходный запрос:
originalRequest.headers.Authorization =
`Bearer ${newAccessToken}`;
return api(originalRequest);
Таким образом
GET /profile
Authorization: Bearer OLD_TOKEN
он пересылается как
GET /profile
Authorization: Bearer NEW_TOKEN
и действие успешно:
200 OK
Конкурентность и механизм повторных попыток
Предположим, что при истечении срока действия токена отправляются четыре запроса:
Promise.all([
api.get("/profile"),
api.get("/students"),
api.get("/teachers"),
api.get("/notifications"),
]);
Каждый получает
401
Без общего объекта promise — четыре операции обновления:
profile → 401 → refresh
students → 401 → refresh
teachers → 401 → refresh
notifications → 401 → refresh
С использованием
let refreshPromise: Promise<string> | null = null;
они сходятся:
profile
↓
401
↓
create refreshPromise
↓
POST /auth/refresh
↑
│
students ───┤
401 │
│
teachers ──┤
401 │
│
notifications
401 │
│
↓
same Promise
↓
NEW TOKEN
и каждый запрос повторяется после одного обновления:
profile → retry
students → retry
teachers → retry
notifications → retry
_retry прерывает циклы. Если повторно отправленный запрос всё ещё отклоняется:
GET /profile
↓
401
↓
refresh
↓
new token
↓
GET /profile again
↓
401
наивная логика вроде
if (status === 401) {
refresh();
retry();
}
вечно циклирует:
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
retry
↓
401
↓
refresh
↓
...
Установка
originalRequest._retry = true;
и проверка
!originalRequest._retry
позволяют одну попытку повтора на запрос.
Отдельный клиент для обновления
Проверка URL крайне чувствительна. Оставьте основной клиент
const api = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
и добавьте ещё один без интерцепторов:
const refreshClient = axios.create({
baseURL: "http://localhost:5000",
withCredentials: true,
});
refreshPromise = refreshClient
.post("/auth/refresh")
.then(...)
Обновление обходит интерцепторы аутентификации.
Полная картина
Весь процесс в одной диаграмме:
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
Замечания для производства
- Используйте защищённые куки
Secureчерез HTTPS. - Храните токены доступа в памяти, а не в
localStorage.