Разработка · 6 мин чтения

Разработать SPA на React с авторизацией: как устроена архитектура

Когда мне ставят задачу разработать SPA на React с авторизацией, в первую очередь уточняю не фреймворк и не менеджер состояния, а то, как бэкенд планирует выдавать и обновлять токены. От этого зависит вся архитектура: где хранить сессию, как защищать роуты и что делать при обрыве соединения на середине работы пользователя. За практику собралось больше десятка таких проектов - от CRM-панелей до SaaS-сервисов - и ниже разложил схему, которая у меня стабильно работает и не превращается в костыль через полгода поддержки.

Из каких блоков состоит React-приложение с авторизацией

Архитектура авторизации в SPA - это связка из пяти частей, которые продумываю до того, как написана первая строчка кода:

  • Auth-контекст или стор (Context API, Zustand, Redux Toolkit) - хранит текущего пользователя и статус сессии
  • API-клиент с интерцептором - подставляет токен в заголовки и ловит 401
  • Protected routes - компонент-обёртка, который решает, пускать на страницу или редиректить на /login
  • Формы логина и регистрации с валидацией - обычно react-hook-form плюс zod
  • Механизм обновления сессии - refresh-токен или silent-relogin через отдельный эндпоинт

На практике большая часть багов в таких проектах происходит не в формах, а на стыке этих частей: токен обновился, а старый запрос в очереди уже улетел с протухшим access-токеном, или редирект на /login срабатывает раньше, чем стор успел прочитать токен при перезагрузке страницы.

Вопрос хранения токена определяет защиту от XSS и CSRF, поэтому сравниваю три варианта, с которыми реально работал:

Способ хранения Плюсы Минусы Когда использую
localStorage просто, доступен из JS, переживает перезагрузку уязвим к XSS, нужно вручную подставлять в заголовки внутренние админки с ограниченным кругом пользователей
httpOnly cookie недоступен для JS-инъекций, браузер сам шлёт с каждым запросом нужен CSRF-токен, сложнее с кросс-доменными запросами публичные SPA с реальными клиентами
память (переменная в сторе) не читается из devtools после перезагрузки, максимально безопасен при XSS слетает при обновлении страницы, нужен silent refresh связка с httpOnly refresh-cookie

Для проектов, где авторизуются реальные клиенты, а не только я и заказчик, беру связку: access-токен живёт в памяти стора, refresh - в httpOnly cookie. XSS не может украсть долгоживущий токен, а UX не ломается при обновлении вкладки.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Access и refresh токены: как обновлять сессию без разлогина

Access-токен обычно живёт 15-30 минут, refresh - от недели до месяца. Задача - обновить access до того, как он протухнет, и сделать это прозрачно для запросов, которые уже стоят в очереди в момент истечения токена.

let isRefreshing = false;
let failedQueue = [];

function processQueue(error, token = null) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) reject(error);
    else resolve(token);
  });
  failedQueue = [];
}

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return api(originalRequest);
        });
      }
      originalRequest._retry = true;
      isRefreshing = true;
      try {
        const { data } = await api.post('/auth/refresh');
        setAccessToken(data.accessToken);
        processQueue(null, data.accessToken);
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        logout();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
    return Promise.reject(error);
  }
);

Ключевой момент - очередь failedQueue: без неё несколько параллельных запросов, получивших 401 одновременно, отправят несколько запросов на refresh, и часть из них получит отказ, если на бэкенде включена ротация refresh-токенов, где каждый refresh инвалидирует предыдущий.

Защищённые роуты в React Router: PrivateRoute и редиректы

В React Router v6 защиту роутов делаю через компонент-обёртку, а не через проверки внутри каждой страницы:

function PrivateRoute({ children }) {
  const { user, isLoading } = useAuth();

  if (isLoading) {
    return <Spinner />;
  }

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

<Route
  path="/dashboard"
  element={
    <PrivateRoute>
      <Dashboard />
    </PrivateRoute>
  }
/>

Отдельно обрабатываю состояние загрузки во время первой проверки токена при заходе на сайт - без него пользователь на долю секунды видит форму логина, даже если сессия валидна, потому что стор ещё не успел сходить на /me.

Авторизация через соцсети и уведомления в мессенджерах

OAuth через Яндекс ID, VK ID или Google в SPA делаю так: фронт редиректит на страницу провайдера, тот возвращает code на callback-урл бэкенда, и уже бэкенд обменивает code на токены провайдера и выдаёт свой JWT. Хранить токены Google или VK на фронте нет смысла - они нужны только один раз, чтобы получить данные профиля.

На одном SaaS-проекте добавляли ещё и подтверждение входа одноразовым кодом через Telegram-бота на aiogram - бэкенд генерировал код, бот присылал его пользователю, а фронт просто показывал поле ввода и ждал подтверждения через polling эндпоинта. Заготовки под такие сценарии - обмен OAuth-кода на токены, стор авторизации, обвязку для one-time кода - собирал в подборке готовых скриптов для авторизации в SPA, чтобы не писать одно и то же с нуля на каждом проекте.

Частые ошибки при разработке SPA с авторизацией на React

  • Хранение access и refresh в одном месте - при компрометации теряются обе сессии сразу
  • Отсутствие ротации refresh-токенов - угнанный токен работает неделями и месяцами
  • Проверка авторизации только на фронте - API остаётся открытым, роут просто прячут в интерфейсе
  • Забытый logout на бэкенде - токен удалили из localStorage, а в базе он всё ещё валиден
  • Жёсткий редирект на /login при любой 401, включая сетевые обрывы - раздражает пользователей на нестабильном интернете

На один такой проект - CRM на React для оптовой торговли - заказчик пришёл после подрядчика, который вообще не проверял токен на бэкенде: фронт скрывал кнопки, но запрос с любым JWT из localStorage открывал весь API. Переписывал модуль авторизации отдельно от остального интерфейса, это заняло около двух недель поверх уже готовой CRM.

На рынке за подобный аудит и переделку авторизации в готовом SPA студии и фрилансеры берут от 60 000 до 150 000 ₽ в зависимости от масштаба API - это не мои расценки, а ориентир по рынку, я оцениваю такие доработки индивидуально после ревью кода. Разработка SPA или веб-сервиса с нуля у меня - от 150 000 ₽, а разовая консультация по архитектуре перед стартом - от 3 000 ₽.

Когда нужен не сайт, а сервис

SaaS / SPA

от 300 000 ₽

Подробнее →

Частые вопросы

Сколько времени занимает разработать SPA на React с авторизацией с нуля?

На практике MVP с логином, регистрацией, защищёнными роутами и refresh-токенами занимает 3-4 недели при одном разработчике, если бэкенд для авторизации уже готов. Если нужен ещё и бэкенд с базой пользователей - закладываю 5-7 недель. Добавление OAuth через соцсети или подтверждения через Telegram-бота - ещё 5-7 дней сверху.

Какой стейт-менеджер лучше для авторизации в React - Redux или Context API?

Для одного стора авторизации хватает Context API с useReducer или Zustand - оба быстрее в настройке, чем Redux Toolkit, и не тянут за собой лишний boilerplate. Redux имеет смысл брать, если в проекте уже есть сложный state за пределами авторизации - корзина, фильтры, real-time данные - и авторизация становится просто ещё одним слайсом.

Можно ли сделать авторизацию SPA без бэкенда, на Firebase или Supabase?

Да, для MVP и внутренних инструментов это ускоряет запуск в разы - Firebase Auth или Supabase Auth закрывают хранение паролей, OAuth-провайдеров и refresh-токены из коробки. Но как только появляются кастомные роли, сложные права доступа или интеграция с существующей базой клиентов, например той же CRM, быстрее написать свой auth-сервис, чем гнуть готовое решение под чужую бизнес-логику.

Как защитить SPA с авторизацией от угона токена через XSS?

Первый шаг - не хранить долгоживущий токен в localStorage, а держать access в памяти и refresh в httpOnly cookie с флагами Secure и SameSite=Strict или Lax. Второй - санитизировать любой пользовательский HTML перед рендером, не использовать dangerouslySetInnerHTML без DOMPurify, и настроить Content-Security-Policy на уровне сервера, чтобы посторонние скрипты не подгружались вообще.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Самозанятый Калинкин Н. А. · работаю с физлицами и юрлицами

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно