Разбор 08

Баги авторизации: вход, вылеты, JWT

Не работал вход, залогиненных пользователей выкидывало посреди сессии, а refresh-токен не обновлялся. Чинил auth-флоу на React + JWT API.

JWT React axios interceptor refresh token httpOnly cookie auth
Клиент
SaaS-платформа, B2B-кабинет
Стек
React 18 SPA, Node.js + Express, JWT (access + refresh)
Срок
3 дня
Итог
Вход, тихий refresh и стабильная сессия восстановлены

Суть проблемы

SPA на React обращалась к Express-бэкенду, который выдавал access JWT с коротким TTL и refresh-токен. В продакшене посыпались жалобы трёх типов: «не могу войти», «выкидывает через минуту», «иногда ломается после простоя». Поведение было плавающим — у части пользователей всё работало, у других вход не проходил вовсе.

Поведение системы До

Вход отклоняет валидные креды. Любой 401 = немедленный logout. Refresh-токен лежит в localStorage и не отправляется на /refresh. При протухании access-токена N параллельных запросов порождают N рефрешей, второй из них убивает первый.

Поведение системы После

Вход проверяет пароль через bcrypt.compare. 401 триггерит единый refresh с очередью запросов. Refresh живёт в httpOnly secure cookie и отправляется автоматически. При протухании выполняется ровно один /refresh, остальные запросы ждут и повторяются.

Как диагностировал

Каждый симптом имел свою корневую причину, но все они пересекались в одном — auth-флоу собран наспех, без учёта жизненного цикла токенов. Шаги ниже в том порядке, в котором я их проходил.

  1. Шаг 1 — Воспроизвёл входЗавёл тестового пользователя, открыл Network. POST /api/auth/login возвращал 401. Сравнил payload с ожидаемой схемой — фронт отправлял { email, password }, а контроллер на бэкенде ждал { username, password }, причём после миграции на bcrypt сравнение делалось через === с хэшем из базы. Двойная поломка: и поле, и проверка.
  2. Шаг 2 — Проверил выдачу токеновВ auth.controller.js нашёл, что access JWT подписывается на JWT_SECRET, а проверяется middleware на process.env.SECRET — две разные переменные окружения. На локале они случайно совпадали, в проде — нет, отсюда «у части пользователей работает».
  3. Шаг 3 — Проследил выкидываниеВ axios-интерсепторе на 401 стоял безусловный window.location = '/login'. Никакой попытки refresh. Access-токен имел TTL 15 минут — ровно через 15 минут после логина любой запрос выбрасывал пользователя.
  4. Шаг 4 — Разобрался с «не обновляется»/api/auth/refresh на бэкенде читал refresh-токен из req.cookies.refreshToken (httpOnly cookie). Но фронт клал токен в localStorage и слал его в body, причём axios-клиент создавался без withCredentials: true — cookie до бэкенда не доходила. Refresh всегда падал с 400.
  5. Шаг 5 — Воспроизвел гонку рефрешейОткрыл дашборд, дождался протухания access, перезагрузил вкладку. В Network видно 5 одновременных POST /api/auth/refresh. Бэкенд использовал refresh-rotation: первый рефреш выдавал новый refresh-токен и инвалидировал старый. Оставшиеся 4 запроса приходили со старым токеном → 401 → logout.
  6. Шаг 6 — Сверил с требованиями безопасностиRefresh-токен в localStorage уязвим к XSS — любой внедрённый скрипт крадёт долгоживущий токен. Протокол надо приводить к httpOnly-cookie и убрать токены из JS-доступа.

Что исправил

Чинил послойно: бэкенд-логин и схема → выдача и хранение токенов → axios-интерсептор с очередью → защита от reuse. Ниже — пары «было/стало» по каждому слою.

1. Логин: проверка пароля и схема запроса

Контроллер логина сравнивал пароль через === и использовал несогласованные имена полей. Привёл к bcrypt, единому секрету из env и валидации тела запроса.

auth.controller.jsбыло
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 });
};
auth.controller.jsстало
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.

auth.controller.js — /refreshбыло
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 не ловится
};
auth.controller.js — /refreshстало
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 и очередью отложенных запросов.

api.js — interceptor (наивный)было
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;
api.js — interceptor (с очередью)стало
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;
refresh-токен — в httpOnly cookie, не в localStorage (XSS). Access-токен держим только в памяти (let accessToken), не в localStorage и не в sessionStorage — при перезагрузке страницы он исчезнет, но interceptor тихо обновит его через refresh-cookie.

Результат

После деплоя жалобы на авторизацию прекратились. Сессия держится, токены обновляются прозрачно для пользователя, гонки рефрешей больше нет.

~0% → 100%
100%
Успешность входа валидными кредами
множество → 0
0
Вылетов активных сессий (silent refresh)
N → 1
1
Параллельных рефрешей при протухании
localStorage → httpOnly
httpOnly
Хранение refresh-токена (защита от XSS)

Итог: вход работает для всех пользователей, сессия не обрывается при протухании access-токена, refresh выполняется ровно один раз с постановкой остальных запросов в очередь, а refresh-токен больше недоступен из JavaScript. Rotation с reuse-detection на бэкенде закрывает сценарий кражи refresh-токена: повторное использование отозванного токена инвалидирует всю семью токенов пользователя.

Auth-флоу приведён к стандартной схеме access (память, 15m) + refresh (httpOnly cookie, 7d, rotation). Поведение перестало зависеть от того, сколько параллельных запросов отправил дашборд.
Есть похожая проблема?

Сначала найдём причину. Потом решим, что действительно нужно исправить.

Опишите симптом и что из-за него перестало работать. Я посмотрю, где искать причину и насколько задача похожа на точечный фикс.

Описать проблему ↗