Современные клиентские приложения редко работают без механизма аутентификации. В приложениях на React, Vue, Angular и других SPA-фреймворках пользователь после входа получает специальные токены, подтверждающие его личность и права доступа.
В связке с TanStack Query обработка токенов становится особенно важной, поскольку библиотека активно управляет сетевыми запросами, кешем, повторными запросами и обновлением данных.
Чаще всего используются два типа токенов:
Access token — короткоживущий токен доступа, который отправляется в заголовке Authorization:
Authorization: Bearer eyJhbGciOi...
Обычно срок жизни такого токена:
После истечения срока сервер начинает возвращать ошибку:
401 Unauthorized
Refresh token используется для получения нового access token без повторного входа пользователя.
Как правило:
Такой подход считается более безопасным.
При работе с TanStack Query появляются типичные сложности:
Без правильной архитектуры приложение начинает:
Наиболее безопасный вариант для SPA — хранение access token в памяти приложения.
Пример:
let accessToken: string | null = null
export const authStore = {
getToken() {
return accessToken
},
setToken(token: string | null) {
accessToken = token
},
}
Преимущества:
Недостаток:
Менее безопасный, но распространённый вариант.
localStorage.setItem('access_token', token)
Минусы:
Использование localStorage допустимо:
Наиболее распространённая production-схема:
Преимущества:
Сервер автоматически получает cookie при запросе:
fetch('/auth/refresh', {
method: 'POST',
credentials: 'include',
})
Обычно создаётся централизованный 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()
}
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
export function useProfile() {
return useQuery({
queryKey: ['profile'],
queryFn: () => api('/api/profile'),
})
}
Типичный сценарий:
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 request одновременно.
Все остальные запросы должны ждать завершения обновления токена.
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
}
}
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()
}
Если refresh token:
то refresh endpoint вернёт:
401 Unauthorized
В таком случае необходимо:
import { queryClient } from './queryClient'
export async function logout() {
authStore.setToken(null)
queryClient.clear()
}
TanStack Query хранит:
Без очистки новый пользователь может увидеть кеш предыдущего пользователя.
Это критическая проблема безопасности.
Иногда предпочтительнее использовать resetQueries:
queryClient.resetQueries()
Разница:
| Метод | Поведение |
|---|---|
| clear() | полностью удаляет кеш |
| resetQueries() | сбрасывает состояние |
| invalidateQueries() | помечает данные устаревшими |
Для logout чаще используется именно:
queryClient.clear()
TanStack Query по умолчанию повторяет failed requests.
Если сервер отвечает:
401 Unauthorized
то retry бесполезен.
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
retry(failureCount, error: any) {
if (error.status === 401) {
return false
}
return failureCount < 3
},
})
export class AuthError extends Error {
constructor(message = 'Unauthorized') {
super(message)
}
}
if (response.status === 401) {
throw new AuthError()
}
retry(failureCount, error) {
if (error instanceof AuthError) {
return false
}
return failureCount < 3
}
import {
QueryCache,
QueryClient,
} from '@tanstack/react-query'
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError(error) {
if (error instanceof AuthError) {
logout()
}
},
}),
})
Mutation используют тот же API client.
export function useUpdateProfile() {
return useMutation({
mutationFn: (payload) =>
api('/api/profile', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
})
}
Mutation могут:
Если mutation получает 401:
Правильная реализация API layer автоматически решает эту задачу.
Если refresh endpoint сам возвращает 401, возможен бесконечный цикл:
request -> 401
refresh -> 401
refresh again -> 401
refresh again -> 401
if (
response.status === 401 &&
url !== '/auth/refresh'
) {
await refreshAccessToken()
}
После reload страницы access token в памяти исчезает.
Требуется:
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 {
//
}
}
await bootstrapAuth()
root.render(<App />)
При использовании Suspense особенно важно завершить auth bootstrap до монтирования приложения.
Иначе:
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>
)
}
Если пользователь:
остальные вкладки всё ещё содержат старый access token.
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 требуется обновить данные пользователя.
await queryClient.invalidateQueries({
queryKey: ['profile'],
})
После авторизации можно заранее загрузить критические данные.
await queryClient.prefetchQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
})
Некоторые backend-системы выдают новый access token на каждый запрос.
Схема работы:
const newToken = response.headers.get(
'x-access-token',
)
if (newToken) {
authStore.setToken(newToken)
}
Иногда сервер ротирует и refresh token.
Старый refresh token:
Это повышает безопасность системы.
Silent auth — незаметное обновление сессии без участия пользователя.
Обычно реализуется через:
Можно обновлять access token заранее.
Например:
setInterval(async () => {
try {
await refreshAccessToken()
} catch {
logout()
}
}, 13 * 60 * 1000)
JWT обычно содержит поле exp:
{
"exp": 1716820000
}
Можно декодировать token и вычислять:
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
}
Вместо ожидания 401 можно обновлять token заранее.
if (isExpired(token)) {
await refreshAccessToken()
}
Такой подход уменьшает:
В SSR-приложениях:
токены обрабатываются иначе.
На сервере:
Для SSR предпочтительнее:
В этом случае TanStack Query получает уже авторизованные данные.
Это одна из самых опасных ошибок frontend-аутентификации.
XSS-атака получает полный контроль над аккаунтом.
Токены никогда не должны передаваться через HTTP.
Только:
https://
Refresh cookie должна содержать:
Secure
HttpOnly
SameSite=Lax
или:
SameSite=Strict
Cookie отправляется только внутри сайта.
Максимальная защита.
Минус:
Компромисс между безопасностью и удобством.
Наиболее популярный вариант.
Требует:
Secure
Используется для cross-site authentication.
Если refresh token хранится в cookie, появляется риск CSRF.
Типичные защиты:
Если устройство теряет сеть:
Важно различать:
try {
await refreshAccessToken()
} catch (error) {
if (!navigator.onLine) {
return
}
logout()
}
После авторизации обычно обновляются:
queryClient.invalidateQueries()
Полезно разделять:
Например:
['public', 'posts']
['private', 'profile']
Это упрощает selective cache cleanup.
queryClient.removeQueries({
queryKey: ['private'],
})
Неавторизованные пользователи не должны выполнять private query.
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
enabled: !!token,
})
Без bootstrap механизма возможно:
Это создаёт визуальные скачки интерфейса.
Правильная инициализация auth state полностью устраняет эту проблему.