Большинство современных API используют схему аутентификации на основе двух токенов:
access token — короткоживущий токен доступаrefresh token — долгоживущий токен обновленияaccess token передаётся в каждом запросе и подтверждает
право клиента выполнять операции. После истечения срока действия сервер
начинает возвращать ошибку:
401 Unauthorized
Если приложение не умеет автоматически обновлять токен, пользователь сталкивается с:
TanStack Query позволяет построить полностью автоматическую систему обновления токенов без ручного вмешательства пользователя.
Обычно система состоит из следующих частей:
React UI
↓
TanStack Query
↓
API layer (fetch / axios)
↓
Access Token
↓
Backend API
При получении 401 происходит:
access token.Особенности:
localStorage;Пример:
{
"accessToken": "eyJhbGciOi...",
"expiresIn": 900
}
Особенности:
httpOnly cookie.Пример:
{
"refreshToken": "7d9a-91ff-11..."
}
Автоматическое обновление токенов почти всегда реализуется вне компонентов.
Правильная архитектура:
UI
↓
hooks/useQuery
↓
api client
↓
auth interceptor
↓
backend
Компоненты не должны знать:
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'),
})
Без механизма обновления:
error;Основная идея:
Если сервер вернул 401:
обновить токен
повторить запрос
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
}
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 даже не узнаёт о проблеме.
С точки зрения 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
возникают:
Необходимо гарантировать:
Одновременно может выполняться только один refresh
let refreshPromise = null
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-запрос.
TanStack Query умеет автоматически повторять запросы:
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
retry: 3,
})
Но retry не должен использоваться для обновления токена.
Retry делает:
401 → повтор
401 → повтор
401 → повтор
Но access token уже недействителен.
Нужен именно:
401 → refresh token → повтор запроса
Многие приложения используют axios.
import axios from 'axios'
export const api = axios.create({
baseURL: 'https://api.example.com',
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
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.
Если refresh token тоже истёк:
POST /auth/refresh
401 Unauthorized
необходимо:
function logout() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
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
}
}
После logout желательно очищать query cache.
import { queryClient } from './queryClient'
function logout() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
queryClient.clear()
window.location.href = '/login'
}
Это предотвращает:
TanStack Query автоматически обновляет данные:
Во время background refetch access token тоже может истечь.
Корректная архитектура refresh token автоматически обрабатывает такие случаи.
Правильно реализованный refresh создаёт эффект:
Пользователь никогда не замечает истечения access token
Это называется:
silent authentication
Некоторые приложения обновляют токен заранее:
access token expires in 2 minutes
→ refresh before expiration
JWT содержит поле:
{
"exp": 1717000000
}
function parseJwt(token) {
return JSON.parse(atob(token.split('.')[1]))
}
function isTokenExpired(token) {
const decoded = parseJwt(token)
return decoded.exp * 1000 < Date.now()
}
async function getValidToken() {
let token = localStorage.getItem('access_token')
if (isTokenExpired(token)) {
token = await refreshAccessToken()
}
return token
}
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 → refresh → retry
С proactive refresh:
refresh → success
Не возникает двойного запроса:
request → 401
request → retry
Пользователь не видит:
Плюсы:
Минусы:
Более безопасный вариант:
refresh token хранится в cookie
JavaScript не имеет к нему доступа.
Frontend:
хранит access token
Browser:
автоматически отправляет refresh cookie
Backend:
валидирует refresh token
await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include',
})
Без:
credentials: 'include'
браузер не отправит cookie.
В SSR-приложениях:
нельзя полагаться только на localStorage.
Во время server-side rendering:
window отсутствует
localStorage отсутствует
Обычно используют:
Access token может использоваться:
После refresh необходимо:
Оптимистические обновления особенно чувствительны к auth-ошибкам.
Сценарий:
1. mutation optimistic update
2. сервер возвращает 401
3. refresh token
4. mutation retry
Если refresh реализован корректно:
TanStack Query mutation:
const mutation = useMutation({
mutationFn: updateProfile,
})
автоматически использует тот же API layer.
Поэтому refresh работает одинаково:
Крупные приложения обычно выделяют:
/auth
authStore.js
authApi.js
tokenManager.js
refreshManager.js
Пример архитектуры:
class TokenManager {
getAccessToken() {}
setAccessToken() {}
clearTokens() {}
async refresh() {}
}
Все операции с токенами происходят централизованно.
Изменение механизма хранения не требует переписывания query.
Можно перейти:
localStorage
→ cookies
→ memory storage
→ secure storage
без изменения бизнес-логики.
Плохой подход:
if (error.status === 401) {
await refreshToken()
}
Компоненты не должны заниматься auth-логикой.
Приводит к:
Нельзя:
retry: true
для auth-ошибок.
Это увеличивает риск компрометации через XSS.
Нельзя пытаться обновлять refresh token бесконечно.
useQuery/useMutation
TanStack Query
fetch/axios client
token manager
refresh manager
401 interceptor
JWT validation
refresh endpoint
rotation
revocation
Некоторые backend-системы выдают:
новый access token
новый refresh token
при каждом refresh.
{
"accessToken": "...",
"refreshToken": "..."
}
localStorage.setItem(
'access_token',
data.accessToken
)
localStorage.setItem(
'refresh_token',
data.refreshToken
)
Она уменьшает риск:
После login или refresh иногда требуется:
queryClient.invalidateQueries()
Это полезно, если:
Если refresh выполняется без интернета:
refresh failed
network error
TanStack Query может автоматически повторить запрос позже после reconnect.
Большой staleTime уменьшает:
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
staleTime: 1000 * 60 * 10,
})
При polling:
refetchInterval: 5000
истечение access token происходит особенно часто.
Корректный auth layer становится критически важным.
Наиболее распространённая production-схема:
Access token:
memory storage
Refresh token:
httpOnly secure cookie
Access token исчезает после перезагрузки страницы.
Это уменьшает последствия XSS.
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 продолжает работу без ошибок