Когда мне ставят задачу разработать 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 срабатывает раньше, чем стор успел прочитать токен при перезагрузке страницы.
Где хранить токены: localStorage, cookie или память
Вопрос хранения токена определяет защиту от 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 на уровне сервера, чтобы посторонние скрипты не подгружались вообще.