Автоматическое обновление токенов

Большинство современных API используют схему аутентификации на основе двух токенов:

  • access token — короткоживущий токен доступа
  • refresh token — долгоживущий токен обновления

access token передаётся в каждом запросе и подтверждает право клиента выполнять операции. После истечения срока действия сервер начинает возвращать ошибку:

401 Unauthorized

Если приложение не умеет автоматически обновлять токен, пользователь сталкивается с:

  • внезапными разлогиниваниями;
  • потерей состояния интерфейса;
  • сбросом форм;
  • ошибками повторных запросов;
  • плохим UX.

TanStack Query позволяет построить полностью автоматическую систему обновления токенов без ручного вмешательства пользователя.


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

Обычно система состоит из следующих частей:

React UI
   ↓
TanStack Query
   ↓
API layer (fetch / axios)
   ↓
Access Token
   ↓
Backend API

При получении 401 происходит:

  1. Перехват ошибки.
  2. Запрос на обновление токена.
  3. Получение нового access token.
  4. Повтор оригинального запроса.
  5. Продолжение работы приложения.

Типичный жизненный цикл токенов

Access token

Особенности:

  • живёт недолго;
  • хранится в памяти или localStorage;
  • используется во всех API-запросах.

Пример:

{
  "accessToken": "eyJhbGciOi...",
  "expiresIn": 900
}

Refresh token

Особенности:

  • живёт дольше;
  • используется только для обновления access token;
  • часто хранится в httpOnly cookie.

Пример:

{
  "refreshToken": "7d9a-91ff-11..."
}

Централизованный API-клиент

Автоматическое обновление токенов почти всегда реализуется вне компонентов.

Правильная архитектура:

UI
 ↓
hooks/useQuery
 ↓
api client
 ↓
auth interceptor
 ↓
backend

Компоненты не должны знать:

  • как обновляется токен;
  • где хранится refresh token;
  • как повторяется запрос.

Базовый API-клиент

Вариант с fetch

const API_URL = 'https://api.example.com'

export async function api(url, options = {}) {
  const token = localStorage.getItem('access_token')

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

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

  return response.json()
}

Проблема истечения токена

Когда access token устаревает:

GET /profile
401 Unauthorized

TanStack Query получает ошибку:

const query = useQuery({
  queryKey: ['profile'],
  queryFn: () => api('/profile'),
})

Без механизма обновления:

  • query переходит в error;
  • интерфейс показывает ошибку;
  • пользователь теряет доступ к данным.

Перехват 401 ошибок

Основная идея:

Если сервер вернул 401:
  обновить токен
  повторить запрос

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

Функция обновления токена

async function refreshAccessToken() {
  const refreshToken = localStorage.getItem('refresh_token')

  const response = await fetch('/auth/refresh', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      refreshToken,
    }),
  })

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

  const data = await response.json()

  localStorage.setItem('access_token', data.accessToken)

  return data.accessToken
}

Автоматический повтор запроса

Расширенный API-клиент

async function api(url, options = {}) {
  let token = localStorage.getItem('access_token')

  const executeRequest = async () => {
    return fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    })
  }

  let response = await executeRequest()

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

    response = await executeRequest()
  }

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

  return response.json()
}

Теперь TanStack Query даже не узнаёт о проблеме.


Поведение TanStack Query

С точки зрения query:

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

происходит:

1. Query выполняется
2. Сервер возвращает 401
3. API layer обновляет токен
4. Запрос повторяется
5. Query получает успешный ответ

Для UI всё выглядит как обычная успешная загрузка.


Проблема параллельных запросов

Одна из самых опасных проблем — множественные refresh-запросы.

Сценарий:

/profile  → 401
/posts    → 401
/settings → 401

Если каждый запрос начнёт обновлять токен самостоятельно:

refresh #1
refresh #2
refresh #3

возникают:

  • гонки состояния;
  • потеря токенов;
  • invalid refresh token;
  • случайные logout;
  • перезапись access token.

Блокировка refresh-запросов

Необходимо гарантировать:

Одновременно может выполняться только один refresh

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

let refreshPromise = null

Обновлённая функция refresh

async function refreshAccessToken() {
  if (!refreshPromise) {
    refreshPromise = fetch('/auth/refresh', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        refreshToken: localStorage.getItem('refresh_token'),
      }),
    })
      .then(async response => {
        if (!response.ok) {
          throw new Error('Refresh failed')
        }

        const data = await response.json()

        localStorage.setItem(
          'access_token',
          data.accessToken
        )

        return data.accessToken
      })
      .finally(() => {
        refreshPromise = null
      })
  }

  return refreshPromise
}

Что происходит теперь

При трёх одновременных 401:

Request A → refresh token
Request B → ждёт refreshPromise
Request C → ждёт refreshPromise

Выполняется только один refresh-запрос.


Retry в TanStack Query и refresh token

TanStack Query умеет автоматически повторять запросы:

useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  retry: 3,
})

Но retry не должен использоваться для обновления токена.


Почему retry не заменяет refresh

Retry делает:

401 → повтор
401 → повтор
401 → повтор

Но access token уже недействителен.

Нужен именно:

401 → refresh token → повтор запроса

Правильное разделение ответственности

TanStack Query отвечает за:

  • кэш;
  • повтор сетевых ошибок;
  • синхронизацию данных;
  • refetch;
  • invalidate;
  • background fetching.

API layer отвечает за:

  • access token;
  • refresh token;
  • Authorization headers;
  • обработку 401;
  • повтор запросов после refresh.

Интеграция с Axios

Многие приложения используют axios.


Axios interceptor

Создание клиента

import axios from 'axios'

export const api = axios.create({
  baseURL: 'https://api.example.com',
})

Request interceptor

api.interceptors.request.use(config => {
  const token = localStorage.getItem('access_token')

  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
})

Response interceptor

api.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config

    if (
      error.response?.status === 401 &&
      !originalRequest._retry
    ) {
      originalRequest._retry = true

      const token = await refreshAccessToken()

      originalRequest.headers.Authorization =
        `Bearer ${token}`

      return api(originalRequest)
    }

    return Promise.reject(error)
  }
)

Защита от бесконечного цикла

Без _retry возможен сценарий:

401
→ refresh
→ снова 401
→ refresh
→ снова 401

Это приводит к бесконечному циклу запросов.

Флаг:

originalRequest._retry = true

останавливает повторный refresh.


Logout при невалидном refresh token

Если refresh token тоже истёк:

POST /auth/refresh
401 Unauthorized

необходимо:

  • очистить токены;
  • сбросить пользовательские данные;
  • выполнить logout.

Реализация logout

function logout() {
  localStorage.removeItem('access_token')
  localStorage.removeItem('refresh_token')

  window.location.href = '/login'
}

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

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

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

    return response.json()
  } catch (error) {
    logout()
    throw error
  }
}

Сброс TanStack Query cache

После logout желательно очищать query cache.


Очистка QueryClient

import { queryClient } from './queryClient'

function logout() {
  localStorage.removeItem('access_token')
  localStorage.removeItem('refresh_token')

  queryClient.clear()

  window.location.href = '/login'
}

Это предотвращает:

  • утечку данных предыдущего пользователя;
  • отображение приватного кэша;
  • использование старых query.

Refresh token и background refetch

TanStack Query автоматически обновляет данные:

  • при фокусе окна;
  • reconnect;
  • interval polling;
  • invalidateQueries.

Во время background refetch access token тоже может истечь.

Корректная архитектура refresh token автоматически обрабатывает такие случаи.


Silent authentication

Правильно реализованный refresh создаёт эффект:

Пользователь никогда не замечает истечения access token

Это называется:

silent authentication

Предварительное обновление токена

Некоторые приложения обновляют токен заранее:

access token expires in 2 minutes
→ refresh before expiration

Проверка срока действия JWT

JWT содержит поле:

{
  "exp": 1717000000
}

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

function parseJwt(token) {
  return JSON.parse(atob(token.split('.')[1]))
}

Проверка expiration

function isTokenExpired(token) {
  const decoded = parseJwt(token)

  return decoded.exp * 1000 < Date.now()
}

Refresh до выполнения запроса

async function getValidToken() {
  let token = localStorage.getItem('access_token')

  if (isTokenExpired(token)) {
    token = await refreshAccessToken()
  }

  return token
}

API-клиент с proactive refresh

async function api(url, options = {}) {
  const token = await getValidToken()

  const response = await fetch(url, {
    ...options,
    headers: {
      Authorization: `Bearer ${token}`,
    },
  })

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

  return response.json()
}

Преимущества proactive refresh

Меньше 401 ошибок

Без proactive refresh:
401 → refresh → retry

С proactive refresh:
refresh → success

Меньше лишних запросов

Не возникает двойного запроса:

request → 401
request → retry

Более плавный UX

Пользователь не видит:

  • мерцания загрузки;
  • промежуточных ошибок;
  • скачков состояния.

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

localStorage

Плюсы:

  • простота;
  • доступность;
  • лёгкая интеграция.

Минусы:

  • уязвимость к XSS.

HttpOnly cookie

Более безопасный вариант:

refresh token хранится в cookie

JavaScript не имеет к нему доступа.


Схема с HttpOnly cookie

Frontend:
  хранит access token

Browser:
  автоматически отправляет refresh cookie

Backend:
  валидирует refresh token

Refresh через cookie

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

Важность credentials

Без:

credentials: 'include'

браузер не отправит cookie.


Обновление токенов и SSR

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

  • Next.js;
  • Remix;
  • Nuxt;

нельзя полагаться только на localStorage.


Особенности SSR

Во время server-side rendering:

window отсутствует
localStorage отсутствует

Подход для SSR

Обычно используют:

  • cookies;
  • server middleware;
  • session storage на сервере.

Refresh token и WebSocket

Access token может использоваться:

  • в WebSocket;
  • SSE;
  • realtime subscriptions.

После refresh необходимо:

  • обновить socket token;
  • переподключить соединение.

Refresh и optimistic updates

Оптимистические обновления особенно чувствительны к auth-ошибкам.

Сценарий:

1. mutation optimistic update
2. сервер возвращает 401
3. refresh token
4. mutation retry

Если refresh реализован корректно:

  • optimistic state сохраняется;
  • rollback не происходит;
  • пользователь не замечает ошибки.

Refresh внутри mutation

TanStack Query mutation:

const mutation = useMutation({
  mutationFn: updateProfile,
})

автоматически использует тот же API layer.

Поэтому refresh работает одинаково:

  • для query;
  • для mutation;
  • для background refetch;
  • для polling.

Централизованная auth-система

Крупные приложения обычно выделяют:

/auth
  authStore.js
  authApi.js
  tokenManager.js
  refreshManager.js

Token manager

Пример архитектуры:

class TokenManager {
  getAccessToken() {}

  setAccessToken() {}

  clearTokens() {}

  async refresh() {}
}

Преимущества централизованного token manager

Единая точка контроля

Все операции с токенами происходят централизованно.


Простая поддержка

Изменение механизма хранения не требует переписывания query.


Простая миграция

Можно перейти:

localStorage
→ cookies
→ memory storage
→ secure storage

без изменения бизнес-логики.


Распространённые ошибки

Refresh внутри компонентов

Плохой подход:

if (error.status === 401) {
  await refreshToken()
}

Компоненты не должны заниматься auth-логикой.


Отсутствие mutex

Приводит к:

  • race conditions;
  • invalid refresh token;
  • случайным logout.

Бесконечные retry

Нельзя:

retry: true

для auth-ошибок.


Хранение refresh token в localStorage

Это увеличивает риск компрометации через XSS.


Повтор refresh-запроса

Нельзя пытаться обновлять refresh token бесконечно.


Рекомендуемая архитектура

UI

useQuery/useMutation

Data layer

TanStack Query

Transport layer

fetch/axios client

Auth layer

token manager
refresh manager
401 interceptor

Backend

JWT validation
refresh endpoint
rotation
revocation

Token rotation

Некоторые backend-системы выдают:

новый access token
новый refresh token

при каждом refresh.


Пример rotation

{
  "accessToken": "...",
  "refreshToken": "..."
}

Обновление обоих токенов

localStorage.setItem(
  'access_token',
  data.accessToken
)

localStorage.setItem(
  'refresh_token',
  data.refreshToken
)

Зачем нужна rotation

Она уменьшает риск:

  • кражи refresh token;
  • replay attack;
  • повторного использования старого токена.

Связь refresh token и invalidateQueries

После login или refresh иногда требуется:

queryClient.invalidateQueries()

Это полезно, если:

  • права пользователя изменились;
  • backend начал возвращать новые данные;
  • access scope обновился.

Поведение при offline режиме

Если refresh выполняется без интернета:

refresh failed
network error

TanStack Query может автоматически повторить запрос позже после reconnect.


Refresh и staleTime

Большой staleTime уменьшает:

  • количество запросов;
  • вероятность 401;
  • частоту refresh.

Пример

useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  staleTime: 1000 * 60 * 10,
})

Refresh и polling

При polling:

refetchInterval: 5000

истечение access token происходит особенно часто.

Корректный auth layer становится критически важным.


Безопасная схема хранения

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

Access token:
  memory storage

Refresh token:
  httpOnly secure cookie

Преимущества memory storage

Access token исчезает после перезагрузки страницы.

Это уменьшает последствия XSS.


Полный production flow

1. Login
2. Backend выдаёт:
   - access token
   - refresh cookie

3. Query выполняет запрос
4. Access token истекает
5. API layer получает 401
6. Выполняется refresh
7. Backend выдаёт новый access token
8. Исходный запрос повторяется
9. UI продолжает работу без ошибок