Обработка токенов аутентификации

Современные клиентские приложения редко работают без механизма аутентификации. В приложениях на React, Vue, Angular и других SPA-фреймворках пользователь после входа получает специальные токены, подтверждающие его личность и права доступа.

В связке с TanStack Query обработка токенов становится особенно важной, поскольку библиотека активно управляет сетевыми запросами, кешем, повторными запросами и обновлением данных.

Чаще всего используются два типа токенов:

  • access token
  • refresh token

Access Token

Access token — короткоживущий токен доступа, который отправляется в заголовке Authorization:

Authorization: Bearer eyJhbGciOi...

Обычно срок жизни такого токена:

  • 5 минут
  • 15 минут
  • 1 час

После истечения срока сервер начинает возвращать ошибку:

401 Unauthorized

Refresh Token

Refresh token используется для получения нового access token без повторного входа пользователя.

Как правило:

  • access token хранится в памяти приложения
  • refresh token хранится в HttpOnly cookie

Такой подход считается более безопасным.


Проблемы аутентификации в TanStack Query

При работе с TanStack Query появляются типичные сложности:

  • массовые ошибки 401
  • параллельное обновление токена
  • повтор запросов после refresh
  • гонки состояния
  • очистка кеша после logout
  • обновление токена во время background refetch
  • синхронизация между вкладками браузера

Без правильной архитектуры приложение начинает:

  • бесконечно перезапрашивать refresh endpoint
  • терять авторизацию
  • выполнять дублирующие запросы
  • отправлять запросы со старыми токенами

Архитектура хранения токенов

Хранение access token в памяти

Наиболее безопасный вариант для SPA — хранение access token в памяти приложения.

Пример:

let accessToken: string | null = null

export const authStore = {
  getToken() {
    return accessToken
  },

  setToken(token: string | null) {
    accessToken = token
  },
}

Преимущества:

  • токен исчезает после перезагрузки страницы
  • XSS-атака не может получить token из localStorage
  • отсутствует долговременное хранение

Недостаток:

  • после refresh страницы требуется повторная инициализация сессии

Хранение токена в localStorage

Менее безопасный, но распространённый вариант.

localStorage.setItem('access_token', token)

Минусы:

  • уязвимость к XSS
  • вредоносный скрипт может украсть токен
  • токен остаётся после закрытия вкладки

Использование localStorage допустимо:

  • во внутренних корпоративных системах
  • в low-risk приложениях
  • при отсутствии строгих security-требований

Наиболее распространённая production-схема:

  • access token хранится в памяти
  • refresh token хранится в HttpOnly cookie

Преимущества:

  • JavaScript не имеет доступа к refresh token
  • защита от XSS
  • сервер контролирует refresh lifecycle

Сервер автоматически получает cookie при запросе:

fetch('/auth/refresh', {
  method: 'POST',
  credentials: 'include',
})

Добавление токена в запросы

Базовый fetch wrapper

Обычно создаётся централизованный API-клиент.

import { authStore } from './authStore'

export async function api<T>(
  url: string,
  options: RequestInit = {},
): Promise<T> {
  const token = authStore.getToken()

  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      Authorization: token
        ? `Bearer ${token}`
        : '',
      ...options.headers,
    },
    credentials: 'include',
  })

  if (!response.ok) {
    throw new Error('Request failed')
  }

  return response.json()
}

Использование в queryFn

import { useQuery } from '@tanstack/react-query'
import { api } from './api'

export function useProfile() {
  return useQuery({
    queryKey: ['profile'],
    queryFn: () => api('/api/profile'),
  })
}

Обработка 401 ошибок

Базовая схема refresh token

Типичный сценарий:

  1. запрос получает 401
  2. выполняется refresh token запрос
  3. сервер выдаёт новый access token
  4. оригинальный запрос повторяется

Наивная реализация

async function requestWithRefresh(
  url: string,
  options?: RequestInit,
) {
  let response = await fetch(url, options)

  if (response.status === 401) {
    const refreshResponse = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    })

    if (!refreshResponse.ok) {
      throw new Error('Unauthorized')
    }

    const data = await refreshResponse.json()

    authStore.setToken(data.accessToken)

    response = await fetch(url, {
      ...options,
      headers: {
        ...options?.headers,
        Authorization: `Bearer ${data.accessToken}`,
      },
    })
  }

  return response
}

Проблема такого подхода — параллельные refresh-запросы.


Проблема параллельного refresh

Предположим:

  • одновременно выполняются 10 запросов
  • access token истёк
  • все 10 запросов получают 401

Без дополнительной защиты приложение отправит:

  • 10 refresh запросов
  • 10 повторных оригинальных запросов

Это создаёт:

  • нагрузку на сервер
  • гонки обновления токена
  • случайные logout
  • invalid refresh token ошибки

Mutex для refresh token

Идея блокировки

В приложении должен существовать только один refresh request одновременно.

Все остальные запросы должны ждать завершения обновления токена.


Реализация refresh mutex

let refreshPromise: Promise<string> | null = null

async function refreshAccessToken(): Promise<string> {
  if (refreshPromise) {
    return refreshPromise
  }

  refreshPromise = (async () => {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    })

    if (!response.ok) {
      throw new Error('Refresh failed')
    }

    const data = await response.json()

    authStore.setToken(data.accessToken)

    return data.accessToken
  })()

  try {
    return await refreshPromise
  } finally {
    refreshPromise = null
  }
}

Повтор запросов после refresh

Полная реализация API-клиента

export async function api<T>(
  url: string,
  options: RequestInit = {},
): Promise<T> {
  const executeRequest = async () => {
    const token = authStore.getToken()

    return fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        Authorization: token
          ? `Bearer ${token}`
          : '',
        ...options.headers,
      },
      credentials: 'include',
    })
  }

  let response = await executeRequest()

  if (response.status === 401) {
    try {
      await refreshAccessToken()

      response = await executeRequest()
    } catch {
      authStore.setToken(null)

      throw new Error('Unauthorized')
    }
  }

  if (!response.ok) {
    throw new Error('Request failed')
  }

  return response.json()
}

Logout при ошибке refresh

Если refresh token:

  • просрочен
  • отозван
  • удалён сервером

то refresh endpoint вернёт:

401 Unauthorized

В таком случае необходимо:

  • удалить access token
  • очистить кеш TanStack Query
  • перенаправить пользователя на login page

Очистка Query Cache

QueryClient и logout

import { queryClient } from './queryClient'

export async function logout() {
  authStore.setToken(null)

  queryClient.clear()
}

Почему очистка обязательна

TanStack Query хранит:

  • profile
  • permissions
  • private data
  • cached API responses

Без очистки новый пользователь может увидеть кеш предыдущего пользователя.

Это критическая проблема безопасности.


Reset вместо clear

Иногда предпочтительнее использовать resetQueries:

queryClient.resetQueries()

Разница:

Метод Поведение
clear() полностью удаляет кеш
resetQueries() сбрасывает состояние
invalidateQueries() помечает данные устаревшими

Для logout чаще используется именно:

queryClient.clear()

Retry и токены аутентификации

Проблема автоматических retry

TanStack Query по умолчанию повторяет failed requests.

Если сервер отвечает:

401 Unauthorized

то retry бесполезен.


Отключение retry для 401

useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,

  retry(failureCount, error: any) {
    if (error.status === 401) {
      return false
    }

    return failureCount < 3
  },
})

Централизованный Auth Error

Создание специальной ошибки

export class AuthError extends Error {
  constructor(message = 'Unauthorized') {
    super(message)
  }
}

Использование в API-клиенте

if (response.status === 401) {
  throw new AuthError()
}

Проверка в retry

retry(failureCount, error) {
  if (error instanceof AuthError) {
    return false
  }

  return failureCount < 3
}

Глобальная обработка auth ошибок

QueryCache onError

import {
  QueryCache,
  QueryClient,
} from '@tanstack/react-query'

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError(error) {
      if (error instanceof AuthError) {
        logout()
      }
    },
  }),
})

Mutation и access token

Авторизованные mutation

Mutation используют тот же API client.

export function useUpdateProfile() {
  return useMutation({
    mutationFn: (payload) =>
      api('/api/profile', {
        method: 'PATCH',
        body: JSON.stringify(payload),
      }),
  })
}

Проблема refresh во время mutation

Mutation могут:

  • отправлять формы
  • изменять данные
  • создавать сущности

Если mutation получает 401:

  • refresh должен завершиться
  • mutation должна повториться

Правильная реализация API layer автоматически решает эту задачу.


Предотвращение бесконечного refresh цикла

Критическая ошибка

Если refresh endpoint сам возвращает 401, возможен бесконечный цикл:

request -> 401
refresh -> 401
refresh again -> 401
refresh again -> 401

Исключение refresh endpoint

if (
  response.status === 401 &&
  url !== '/auth/refresh'
) {
  await refreshAccessToken()
}

Инициализация сессии

После reload страницы access token в памяти исчезает.

Требуется:

  1. попытка refresh
  2. восстановление access token
  3. только потом рендер приложения

Bootstrap Auth

Инициализация приложения

async function bootstrapAuth() {
  try {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    })

    if (!response.ok) {
      return
    }

    const data = await response.json()

    authStore.setToken(data.accessToken)
  } catch {
    //
  }
}

Ожидание bootstrap

await bootstrapAuth()

root.render(<App />)

Suspense и auth bootstrap

При использовании Suspense особенно важно завершить auth bootstrap до монтирования приложения.

Иначе:

  • запросы стартуют без токена
  • появляются массовые 401
  • запускаются ненужные refresh операции

React Context для auth

AuthProvider

type AuthContextValue = {
  token: string | null
  setToken(token: string | null): void
}

const AuthContext =
  createContext<AuthContextValue | null>(null)

Провайдер

export function AuthProvider({
  children,
}: PropsWithChildren) {
  const [token, setToken] = useState<string | null>(
    null,
  )

  return (
    <AuthContext.Provider
      value={{
        token,
        setToken,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

Синхронизация вкладок

Проблема multi-tab logout

Если пользователь:

  • открыл 3 вкладки
  • вышел из аккаунта в одной вкладке

остальные вкладки всё ещё содержат старый access token.


BroadcastChannel API

const channel = new BroadcastChannel('auth')

export function logout() {
  authStore.setToken(null)

  queryClient.clear()

  channel.postMessage('logout')
}

channel.onmess age = (event) => {
  if (event.data === 'logout') {
    authStore.setToken(null)

    queryClient.clear()
  }
}

Инвалидация запросов после login

После успешного login требуется обновить данные пользователя.

await queryClient.invalidateQueries({
  queryKey: ['profile'],
})

Prefetch после login

После авторизации можно заранее загрузить критические данные.

await queryClient.prefetchQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
})

Access Token Rotation

Некоторые backend-системы выдают новый access token на каждый запрос.

Схема работы:

  1. клиент отправляет запрос
  2. сервер возвращает новый token
  3. клиент сохраняет его
  4. следующий запрос использует новый token

Обработка rotation

const newToken = response.headers.get(
  'x-access-token',
)

if (newToken) {
  authStore.setToken(newToken)
}

Refresh Rotation

Иногда сервер ротирует и refresh token.

Старый refresh token:

  • инвалидируется
  • заменяется новым

Это повышает безопасность системы.


Silent Authentication

Silent auth — незаметное обновление сессии без участия пользователя.

Обычно реализуется через:

  • refresh token
  • hidden iframe
  • background refresh
  • session endpoint

Фоновое обновление access token

Refresh до истечения срока

Можно обновлять access token заранее.

Например:

  • token живёт 15 минут
  • refresh выполняется через 13 минут

Пример background refresh

setInterval(async () => {
  try {
    await refreshAccessToken()
  } catch {
    logout()
  }
}, 13 * 60 * 1000)

JWT и срок жизни токена

JWT обычно содержит поле exp:

{
  "exp": 1716820000
}

Можно декодировать token и вычислять:

  • срок жизни
  • время refresh
  • время logout

Декодирование JWT

function parseJwt(token: string) {
  const base64 = token.split('.')[1]

  return JSON.parse(atob(base64))
}

Проверка срока жизни

function isExpired(token: string) {
  const payload = parseJwt(token)

  return Date.now() >= payload.exp * 1000
}

Proactive Refresh

Вместо ожидания 401 можно обновлять token заранее.

if (isExpired(token)) {
  await refreshAccessToken()
}

Такой подход уменьшает:

  • количество failed requests
  • скачки интерфейса
  • вероятность logout race condition

SSR и токены

В SSR-приложениях:

  • Next.js
  • Remix
  • Nuxt

токены обрабатываются иначе.


Проблемы SSR

На сервере:

  • нет localStorage
  • нет window
  • запросы выполняются на backend
  • cookie доступны иначе

Для SSR предпочтительнее:

  • session cookie
  • HttpOnly cookie
  • server-side auth

В этом случае TanStack Query получает уже авторизованные данные.


Security рекомендации

Никогда не хранить refresh token в localStorage

Это одна из самых опасных ошибок frontend-аутентификации.

XSS-атака получает полный контроль над аккаунтом.


Использование HTTPS

Токены никогда не должны передаваться через HTTP.

Только:

https://

Refresh cookie должна содержать:

Secure
HttpOnly
SameSite=Lax

или:

SameSite=Strict

SameSite политика

Strict

Cookie отправляется только внутри сайта.

Максимальная защита.

Минус:

  • неудобство SSO
  • проблемы cross-domain auth

Lax

Компромисс между безопасностью и удобством.

Наиболее популярный вариант.


None

Требует:

Secure

Используется для cross-site authentication.


CSRF и refresh token

Если refresh token хранится в cookie, появляется риск CSRF.

Типичные защиты:

  • SameSite cookie
  • CSRF token
  • double submit cookie
  • origin validation

Обработка offline режима

Если устройство теряет сеть:

  • refresh может завершиться ошибкой
  • не следует немедленно logout пользователя

Важно различать:

  • network error
  • auth error

Проверка типа ошибки

try {
  await refreshAccessToken()
} catch (error) {
  if (!navigator.onLine) {
    return
  }

  logout()
}

Массовая инвалидация после login

После авторизации обычно обновляются:

  • profile
  • notifications
  • settings
  • permissions
queryClient.invalidateQueries()

Разделение public и private query

Полезно разделять:

  • публичные запросы
  • приватные запросы

Например:

['public', 'posts']
['private', 'profile']

Это упрощает selective cache cleanup.


Удаление только private cache

queryClient.removeQueries({
  queryKey: ['private'],
})

Auth-aware query

Условный запуск запросов

Неавторизованные пользователи не должны выполнять private query.

useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  enabled: !!token,
})

Избежание flash unauthorized state

Без bootstrap механизма возможно:

  1. приложение стартует
  2. token ещё не восстановлен
  3. profile query получает 401
  4. пользователь видит logout
  5. refresh восстанавливает сессию

Это создаёт визуальные скачки интерфейса.

Правильная инициализация auth state полностью устраняет эту проблему.