Клиентское кеширование значительно ускоряет работу приложений, однако вместе с производительностью появляется риск хранения конфиденциальной информации в памяти браузера. TanStack Query активно кеширует ответы сервера, повторно использует результаты запросов и автоматически синхронизирует данные между компонентами. Если в кеше оказываются токены авторизации, персональные данные, финансовая информация или закрытые API-ответы, это создаёт потенциальную поверхность атаки.
К чувствительным данным обычно относятся:
Даже если приложение использует HTTPS, чувствительные данные в памяти браузера остаются доступными:
По этой причине TanStack Query требует отдельной стратегии очистки кеша и контроля жизненного цикла данных.
Внутри библиотеки используется объект QueryCache,
содержащий все query-записи. Пока query существует в кеше, её данные
доступны через:
queryClient.getQueryData()
или через Devtools.
Даже после размонтирования компонента данные могут оставаться в памяти:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 10
}
}
})
В данном примере кеш хранится 10 минут после того, как перестал использоваться.
Для чувствительных данных такой подход часто недопустим.
Наиболее важный сценарий — удаление пользовательских данных после выхода из системы.
Неправильная реализация:
const logout = async () => {
await api.post('/logout')
}
В этом случае:
Правильная очистка:
const logout = async () => {
await api.post('/logout')
queryClient.clear()
}
Метод:
queryClient.clear()
полностью очищает:
Это наиболее жёсткий и безопасный вариант.
queryClient.clear()
Удаляет всё.
Используется:
queryClient.removeQueries({
queryKey: ['profile']
})
Удаляются только указанные query.
Подходит:
queryClient.resetQueries({
queryKey: ['profile']
})
Сбрасывает query в initial state.
Отличия:
Если backend возвращает 401, необходимо немедленно удалить чувствительные данные.
Пример глобального обработчика:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false
}
},
queryCache: new QueryCache({
onError: (error) => {
if (error.response?.status === 401) {
queryClient.clear()
}
}
})
})
Такой механизм предотвращает ситуацию, когда:
Наиболее опасная ошибка — сохранение чувствительных данных в localStorage.
Например:
persistQueryClient({
queryClient,
persister
})
По умолчанию persist-механизм может записывать весь кеш целиком.
Если туда попадут:
то информация останется в браузере даже после закрытия вкладки.
Безопасный подход:
persistQueryClient({
queryClient,
persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
return query.meta?.persist !== false
}
}
})
Теперь чувствительные запросы можно помечать:
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
meta: {
persist: false
}
})
Если используется persistence, очистки query cache недостаточно.
Необходимо удалить сохранённый кеш:
const logout = async () => {
queryClient.clear()
localStorage.removeItem('REACT_QUERY_OFFLINE_CACHE')
}
Иначе после перезагрузки страницы данные снова восстановятся.
Для чувствительных данных рекомендуется минимизировать время хранения.
Опасный пример:
useQuery({
queryKey: ['bank-account'],
queryFn: fetchBankAccount,
staleTime: 1000 * 60 * 30,
gcTime: 1000 * 60 * 60
})
Здесь данные могут храниться часами.
Безопаснее:
useQuery({
queryKey: ['bank-account'],
queryFn: fetchBankAccount,
staleTime: 0,
gcTime: 1000 * 30
})
Особенно важно уменьшать:
gcTimestaleTimeИногда чувствительные данные вообще нельзя хранить.
Например:
В таких случаях:
useQuery({
queryKey: ['otp'],
queryFn: fetchOtp,
staleTime: 0,
gcTime: 0
})
или даже:
useQuery({
queryKey: ['otp'],
queryFn: fetchOtp,
networkMode: 'always'
})
Особенно опасен сценарий shared device.
Например:
Причина обычно выглядит так:
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile
})
Ключ query одинаков для всех пользователей.
Безопаснее:
useQuery({
queryKey: ['profile', user.id],
queryFn: fetchProfile
})
Теперь кеш разделяется между пользователями.
Дополнительно:
authStore.subscribe((user) => {
if (!user) {
queryClient.clear()
}
})
Многие забывают, что TanStack Query хранит не только query, но и mutation.
Например:
const mutation = useMutation({
mutationFn: updatePassword
})
Mutation cache может содержать:
После logout рекомендуется:
queryClient.getMutationCache().clear()
Хотя:
queryClient.clear()
делает это автоматически.
Optimistic update может временно содержать чувствительные данные.
Пример:
queryClient.setQueryData(
['profile'],
updatedProfile
)
Если запрос завершится ошибкой, временные данные могут остаться в кеше.
Безопасный rollback:
onError: (error, variables, context) => {
queryClient.setQueryData(
['profile'],
context.previousProfile
)
}
После logout:
queryClient.cancelQueries()
queryClient.clear()
Даже неактивные query могут хранить данные.
Проверка:
queryClient.getQueryCache().getAll()
Удаление:
queryClient.removeQueries({
type: 'inactive'
})
TanStack Query Devtools показывают содержимое кеша.
В production это опасно.
Неправильно:
<ReactQueryDevtools />
Правильно:
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools />
)}
Иначе любой пользователь сможет увидеть:
Дополнительная мера безопасности:
window.addEventListener('beforeunload', () => {
queryClient.clear()
})
Особенно полезно:
Некоторые системы требуют auto logout.
Пример:
let inactivityTimer
const resetTimer = () => {
clearTimeout(inactivityTimer)
inactivityTimer = setTimeout(() => {
queryClient.clear()
}, 1000 * 60 * 15)
}
window.addEventListener('mousemove', resetTimer)
window.addEventListener('keydown', resetTimer)
Если logout произошёл в одной вкладке, остальные вкладки также должны удалить данные.
Пример через storage event:
window.addEventListener('storage', (event) => {
if (event.key === 'logout') {
queryClient.clear()
}
})
Logout:
localStorage.setItem('logout', Date.now())
Критическая ошибка — хранение access token внутри query.
Плохо:
useQuery({
queryKey: ['auth'],
queryFn: async () => {
return {
token,
user
}
}
})
Токены не должны находиться:
Лучше:
Backend иногда возвращает лишние данные.
Например:
{
"id": 1,
"email": "user@test.com",
"passwordHash": "...",
"internalFlags": [],
"token": "..."
}
Перед сохранением:
useQuery({
queryKey: ['profile'],
queryFn: async () => {
const data = await fetchProfile()
return {
id: data.id,
email: data.email
}
}
})
Infinite Query хранит массив страниц.
Например:
data.pages
Если страницы содержат чувствительные данные, очистка становится особенно важной:
queryClient.removeQueries({
queryKey: ['transactions']
})
При SSR TanStack Query сериализует кеш:
dehydrate(queryClient)
Если не фильтровать данные, чувствительная информация попадёт в HTML страницы.
Безопасная фильтрация:
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
return query.meta?.sensitive !== true
}
})
Чувствительный query:
useQuery({
queryKey: ['private'],
queryFn: fetchPrivateData,
meta: {
sensitive: true
}
})
Иногда сервер возвращает большой объект, но UI использует только часть.
Вместо хранения полного ответа:
useQuery({
queryKey: ['user'],
queryFn: fetchUser
})
лучше:
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
select: (data) => ({
id: data.id,
name: data.name
})
})
Это уменьшает объём чувствительной информации в кеше.
Практика безопасного использования TanStack Query обычно строится по следующим принципам:
| Тип данных | Стратегия |
|---|---|
| Public API | Обычное кеширование |
| User profile | Короткий gcTime |
| JWT token | Не хранить в query |
| Банковские данные | Без persistence |
| OTP | Без кеширования |
| Admin data | Полная очистка при logout |
| SSR private data | Исключать из dehydrate |
| Infinite sensitive lists | Агрессивная очистка |
На практике защита строится сразу из нескольких механизмов:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
gcTime: 1000 * 60,
retry: false
}
}
})
const logout = async () => {
queryClient.cancelQueries()
queryClient.clear()
localStorage.removeItem(
'REACT_QUERY_OFFLINE_CACHE'
)
}
Чувствительные query:
useQuery({
queryKey: ['private', user.id],
queryFn: fetchPrivateData,
gcTime: 0,
meta: {
persist: false,
sensitive: true
}
})
Такая схема: