Суть проблемы
SPA на React обращалась к Express-бэкенду, который выдавал access JWT с коротким TTL и refresh-токен. В продакшене посыпались жалобы трёх типов: «не могу войти», «выкидывает через минуту», «иногда ломается после простоя». Поведение было плавающим — у части пользователей всё работало, у других вход не проходил вовсе.
- !Кнопка «Войти» отдаёт 401Правильный логин/пароль, бэкенд отвечает 401 Unauthorized, в систему не пускает. В логах — успешная проверка email, но ошибка сравнения пароля.
- !Пользователя выкидывает посреди работыВкладка открыта, пользователь заполняет форму — и внезапно редирект на /login. Никаких действий со стороны пользователя не было.
- !После простоя страница виснет и редиректит на логинПользователь отошёл на 10 минут, access-токен протух. При возврате запросы падают, refresh не срабатывает, происходит переход на страницу входа без сохранения контекста.
- !Параллельные запросы ломают сессиюПри протухании access-токена дашборд отправляет 5 запросов одновременно. Каждый получает 401 и запускает свой /refresh. Один из рефрешей инвалидирует остальные, сессия «разваливается».
Вход отклоняет валидные креды. Любой 401 = немедленный logout. Refresh-токен лежит в localStorage и не отправляется на /refresh. При протухании access-токена N параллельных запросов порождают N рефрешей, второй из них убивает первый.
Вход проверяет пароль через bcrypt.compare. 401 триггерит единый refresh с очередью запросов. Refresh живёт в httpOnly secure cookie и отправляется автоматически. При протухании выполняется ровно один /refresh, остальные запросы ждут и повторяются.
Как диагностировал
Каждый симптом имел свою корневую причину, но все они пересекались в одном — auth-флоу собран наспех, без учёта жизненного цикла токенов. Шаги ниже в том порядке, в котором я их проходил.
- Шаг 1 — Воспроизвёл входЗавёл тестового пользователя, открыл Network. POST
/api/auth/loginвозвращал 401. Сравнил payload с ожидаемой схемой — фронт отправлял{ email, password }, а контроллер на бэкенде ждал{ username, password }, причём после миграции на bcrypt сравнение делалось через===с хэшем из базы. Двойная поломка: и поле, и проверка. - Шаг 2 — Проверил выдачу токеновВ
auth.controller.jsнашёл, что access JWT подписывается наJWT_SECRET, а проверяется middleware наprocess.env.SECRET— две разные переменные окружения. На локале они случайно совпадали, в проде — нет, отсюда «у части пользователей работает». - Шаг 3 — Проследил выкидываниеВ axios-интерсепторе на 401 стоял безусловный
window.location = '/login'. Никакой попытки refresh. Access-токен имел TTL 15 минут — ровно через 15 минут после логина любой запрос выбрасывал пользователя. - Шаг 4 — Разобрался с «не обновляется»
/api/auth/refreshна бэкенде читал refresh-токен изreq.cookies.refreshToken(httpOnly cookie). Но фронт клал токен вlocalStorageи слал его вbody, причём axios-клиент создавался безwithCredentials: true— cookie до бэкенда не доходила. Refresh всегда падал с 400. - Шаг 5 — Воспроизвел гонку рефрешейОткрыл дашборд, дождался протухания access, перезагрузил вкладку. В Network видно 5 одновременных
POST /api/auth/refresh. Бэкенд использовал refresh-rotation: первый рефреш выдавал новый refresh-токен и инвалидировал старый. Оставшиеся 4 запроса приходили со старым токеном → 401 → logout. - Шаг 6 — Сверил с требованиями безопасностиRefresh-токен в
localStorageуязвим к XSS — любой внедрённый скрипт крадёт долгоживущий токен. Протокол надо приводить к httpOnly-cookie и убрать токены из JS-доступа.
Что исправил
Чинил послойно: бэкенд-логин и схема → выдача и хранение токенов → axios-интерсептор с очередью → защита от reuse. Ниже — пары «было/стало» по каждому слою.
1. Логин: проверка пароля и схема запроса
Контроллер логина сравнивал пароль через === и использовал несогласованные имена полей. Привёл к bcrypt, единому секрету из env и валидации тела запроса.
import jwt from 'jsonwebtoken';
export const login = async (req, res) => {
const { username, password } = req.body; // фронт шлёт email, не username
const user = await User.findOne({ username });
if (!user || user.password !== password) { // === вместо bcrypt
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = jwt.sign(
{ id: user.id, role: user.role },
process.env.SECRET // не совпадает с проверочным JWT_SECRET
);
res.json({ accessToken });
};
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { loginSchema } from '../schemas/auth.js';
export const login = async (req, res) => {
const { error, value } = loginSchema.validate(req.body);
if (error) return res.status(400).json({ error: error.message });
const { email, password } = value;
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = issueRefreshToken(user.id);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
};
2. Выдача и чтение refresh-токена
Refresh больше не возвращается в теле ответа и не хранится в localStorage. Бэкенд пишет его в httpOnly cookie, а /refresh читает оттуда же и поддерживает rotation с reuse-detection.
export const refresh = async (req, res) => {
const { refreshToken } = req.body; // фронт клал сюда localStorage-токен
if (!refreshToken) return res.sendStatus(400);
const payload = jwt.verify(refreshToken, process.env.SECRET);
const accessToken = jwt.sign({ id: payload.id }, process.env.SECRET);
res.json({ accessToken }); // refresh не ротируется, reuse не ловится
};
export const refresh = async (req, res) => {
const refreshToken = req.cookies.refreshToken; // httpOnly cookie
if (!refreshToken) return res.sendStatus(401);
const stored = await RefreshToken.findOne({ token: refreshToken });
if (!stored || stored.expiresAt < new Date()) {
return res.sendStatus(401);
}
// Reuse detection: токен уже был обменян — отзываем всю семью
if (stored.used) {
await RefreshToken.deleteMany({ userId: stored.userId });
return res.sendStatus(401);
}
stored.used = true;
await stored.save();
const newRefresh = issueRefreshToken(stored.userId);
const accessToken = jwt.sign(
{ id: stored.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.cookie('refreshToken', newRefresh, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
};
3. axios-клиент: withCredentials и единый refresh
Сначала — наивная версия, в которой каждый 401 порождает собственный /refresh. Именно она создавала гонку. Затем — версия с флагом isRefreshing и очередью отложенных запросов.
import axios from 'axios';
const api = axios.create({ baseURL: '/api' }); // без withCredentials — cookie не идёт
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(r) => r,
async (error) => {
if (error.response?.status === 401) {
// КАЖДЫЙ 401 запускает свой refresh — гонка
const { data } = await axios.post('/api/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken'),
});
localStorage.setItem('accessToken', data.accessToken);
return api.request(error.config);
}
return Promise.reject(error);
}
);
export default api;
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // отправляем httpOnly cookie на /refresh
});
let accessToken = null; // access живёт только в памяти, не в localStorage
let isRefreshing = false;
let queue = []; // запросы, ждущие обновления токена
export const setAccessToken = (t) => { accessToken = t; };
api.interceptors.request.use((config) => {
if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
api.interceptors.response.use(
(r) => r,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
// Если refresh уже идёт — встаём в очередь и ждём
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({ resolve, reject });
}).then(() => api.request(original));
}
original._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post(
'/api/auth/refresh', {}, { withCredentials: true }
);
setAccessToken(data.accessToken);
// Разблокируем накопившиеся запросы
queue.forEach((p) => p.resolve());
queue = [];
return api.request(original);
} catch (e) {
// Refresh упал — выходим чисто, без гонки
queue.forEach((p) => p.reject(e));
queue = [];
logout();
return Promise.reject(e);
} finally {
isRefreshing = false;
}
}
);
export default api;
let accessToken), не в localStorage и не в sessionStorage — при перезагрузке страницы он исчезнет, но interceptor тихо обновит его через refresh-cookie.Результат
После деплоя жалобы на авторизацию прекратились. Сессия держится, токены обновляются прозрачно для пользователя, гонки рефрешей больше нет.
Итог: вход работает для всех пользователей, сессия не обрывается при протухании access-токена, refresh выполняется ровно один раз с постановкой остальных запросов в очередь, а refresh-токен больше недоступен из JavaScript. Rotation с reuse-detection на бэкенде закрывает сценарий кражи refresh-токена: повторное использование отозванного токена инвалидирует всю семью токенов пользователя.