TanStack Query Authentication: JWT, Sessions, and OAuth Integration
Authentication is one of the most common — and most error-prone — concerns in modern frontend applications. When you combine it with data fetching, you quickly run into questions like: How do I attach tokens to every request? What happens when a token expires? How do I invalidate cached data when a user logs out? TanStack Query (formerly React Query) doesn't ship with a built-in auth system, but its primitives are perfectly suited for building a robust authentication layer. This tutorial walks through integrating JWT, session-based auth, and OAuth with TanStack Query, complete with practical patterns and best practices.
Why Authentication Belongs in Your Query Layer
Most tutorials treat authentication and data fetching as separate concerns. In practice, they are deeply intertwined. Your queries depend on auth state, your mutations change auth state, and your cache must be reset when auth state changes. By co-locating auth logic with TanStack Query, you gain several advantages:
- Automatic retries and refetches respect auth boundaries.
- Token refresh can be handled centrally before failed queries propagate errors.
- Cache invalidation on logout prevents data leakage between users.
- Optimistic UI works naturally for login and registration flows.
Setting Up the Foundation
Before diving into specific auth strategies, let's establish a shared setup. We'll create a QueryClient with sensible defaults and an auth context that exposes the current user and login/logout functions.
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Don't retry on 401 or 403 — those are auth errors
if ([401, 403].includes(error.status)) return false;
return failureCount < 2;
},
staleTime: 60_000,
refetchOnWindowFocus: true,
},
},
});
Next, we define a minimal auth context. This will be the bridge between your auth provider and TanStack Query.
import { createContext, useContext, useState, useCallback } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(() => localStorage.getItem('token'));
const login = useCallback((newToken, newUser) => {
localStorage.setItem('token', newToken);
setToken(newToken);
setUser(newUser);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
queryClient.clear();
}, []);
return (
<AuthContext.Provider value={{ user, token, login, logout, isAuthenticated: !!token }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};
Notice the queryClient.clear() call in logout. This is critical: it wipes all cached data so a newly logged-in user never sees the previous user's queries.
JWT Authentication
JSON Web Tokens are the most common stateless auth mechanism in SPAs. The typical flow: the client sends credentials, receives an access token (and often a refresh token), and attaches the access token to subsequent requests via the Authorization header.
Attaching Tokens with a Custom Fetcher
The cleanest way to inject JWTs into every request is to wrap fetch in a custom function that reads the token from storage. TanStack Query is transport-agnostic, so this works seamlessly.
async function apiFetch(path, options = {}) {
const token = localStorage.getItem('token');
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(`/api${path}`, { ...options, headers });
if (res.status === 401) {
// Trigger refresh or logout
const refreshed = await tryRefreshToken();
if (refreshed) {
return apiFetch(path, options); // retry once
}
throw new AuthError('Session expired', 401);
}
if (!res.ok) {
const error = new Error('Request failed');
error.status = res.status;
error.body = await res.json().catch(() => null);
throw error;
}
return res.json();
}
Handling Token Refresh
Access tokens are short-lived. When they expire, you exchange a refresh token for a new access token. The tricky part is avoiding multiple simultaneous refresh calls when several queries fail at once. A singleton promise solves this.
let refreshPromise = null;
async function tryRefreshToken() {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return false;
// Deduplicate concurrent refresh attempts
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
.then((res) => {
if (!res.ok) throw new Error('Refresh failed');
return res.json();
})
.then(({ token, refreshToken: newRefresh }) => {
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', newRefresh);
return true;
})
.catch(() => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
return false;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
class AuthError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
Login and Register Mutations
With the fetcher in place, login becomes a straightforward mutation that calls login from our auth context on success.
import { useMutation } from '@tanstack/react-query';
import { useAuth } from './AuthProvider';
export function useLogin() {
const { login } = useAuth();
return useMutation({
mutationFn: (credentials) =>
apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
}),
onSuccess: (data) => {
localStorage.setItem('refreshToken', data.refreshToken);
login(data.token, data.user);
},
});
}
// Usage in a component
function LoginForm() {
const login = useLogin();
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
login.mutate({
email: formData.get('email'),
password: formData.get('password'),
});
};
return (
<form onSubmit={handleSubmit}>
{/* inputs */}
{login.isError && <p>Invalid credentials</p>}
<button disabled={login.isPending}>Log in</button>
</form>
);
}
Fetching the Current User
A common pattern is to fetch the authenticated user on app startup so the UI can render correctly. We enable the query only when a token exists.
import { useQuery } from '@tanstack/react-query';
export function useCurrentUser() {
const { token, logout } = useAuth();
return useQuery({
queryKey: ['currentUser'],
queryFn: () => apiFetch('/auth/me'),
enabled: !!token,
retry: false,
onError: (error) => {
if (error.status === 401) logout();
},
});
}
Session-Based Authentication
Session-based auth relies on cookies rather than tokens. The browser automatically sends the session cookie with every same-origin request, so you don't need to manually attach credentials. The main difference from JWT is configuration: you must set credentials: 'include' on your fetcher and ensure your backend sends SameSite and HttpOnly cookies.
Configuring the Fetcher for Sessions
async function sessionFetch(path, options = {}) {
const res = await fetch(`/api${path}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (res.status === 401) {
throw new AuthError('Not authenticated', 401);
}
if (!res.ok) {
const error = new Error('Request failed');
error.status = res.status;
throw error;
}
return res.status === 204 ? null : res.json();
}
Because there's no token to refresh client-side, session expiry is typically handled by the backend. When the session expires, queries return 401 and your global error handler can redirect to login. There's no refresh-token dance, which simplifies the client considerably.
Session Login and Logout
export function useSessionLogin() {
const queryClient = useQueryClient();
const { login } = useAuth();
return useMutation({
mutationFn: (credentials) =>
sessionFetch('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
}),
onSuccess: (user) => {
login('session', user); // token is symbolic here
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
}
export function useSessionLogout() {
const queryClient = useQueryClient();
const { logout } = useAuth();
return useMutation({
mutationFn: () => sessionFetch('/auth/logout', { method: 'POST' }),
onSettled: () => {
logout();
queryClient.clear();
},
});
}
CSRF Protection
Session cookies are vulnerable to CSRF attacks. Most frameworks issue a CSRF token in a separate cookie or meta tag that you must include in mutating requests. Here's a helper that reads the CSRF token from a meta tag and attaches it to unsafe methods.
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta?.getAttribute('content');
}
async function safeSessionFetch(path, options = {}) {
const method = (options.method || 'GET').toUpperCase();
const headers = { ...options.headers };
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const csrf = getCsrfToken();
if (csrf) headers['X-CSRF-Token'] = csrf;
}
return sessionFetch(path, { ...options, headers });
}
OAuth Integration
OAuth (and OIDC) adds complexity because the auth flow involves redirects to and from an external provider. The two main approaches are the authorization code flow with PKCE (for SPAs) and the backend-mediated flow where your server handles the exchange. We'll cover both.
Backend-Mediated OAuth
In this flow, the frontend simply redirects to your backend's OAuth start endpoint, and the backend handles the redirect to the provider, the callback, and the token exchange. After the callback, the backend redirects back to your app with a session or token already set.
function startOAuth(provider) {
window.location.href = `/api/auth/oauth/${provider}`;
}
// After redirect back to the app, fetch the now-authenticated user
function OAuthCallback() {
const { data: user, isLoading } = useCurrentUser();
useEffect(() => {
if (user) {
navigate('/dashboard');
}
}, [user]);
if (isLoading) return <p>Completing sign-in…</p>;
return <p>Redirecting…</p>;
}
Authorization Code Flow with PKCE
For SPAs that talk directly to an OAuth provider, PKCE is the recommended approach. You generate a code verifier and challenge, redirect the user to the provider, and exchange the returned code for tokens. Libraries like oauth4webapi or @azure/msal-browser handle the cryptographic details, but here's the manual flow for clarity.
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function startPkceFlow(provider) {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
redirect_uri: `${window.location.origin}/oauth/callback`,
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
state: provider,
});
window.location.href = `https://provider.example.com/auth?${params}`;
}
On the callback page, exchange the code for tokens and store them just like a JWT login.
export function useOAuthCallback() {
const { login } = useAuth();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
const verifier = sessionStorage.getItem('pkce_verifier');
if (!code || !verifier) throw new Error('Missing OAuth params');
const res = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
code_verifier: verifier,
redirect_uri: `${window.location.origin}/oauth/callback`,
}),
});
if (!res.ok) throw new Error('Token exchange failed');
return res.json();
},
onSuccess: (data) => {
sessionStorage.removeItem('pkce_verifier');
localStorage.setItem('refreshToken', data.refreshToken);
login(data.token, data.user);
queryClient.invalidateQueries();
},
});
}
Global Error Handling
Regardless of which auth strategy you use, a global error handler ensures consistent behavior when sessions expire. The QueryCache and MutationCache accept onError callbacks.
import { QueryCache, MutationCache } from '@tanstack/react-query';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (error.status === 401) {
window.dispatchEvent(new CustomEvent('auth-expired'));
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (error.status === 401) {
window.dispatchEvent(new CustomEvent('auth-expired'));
}
},
}),
defaultOptions: {
queries: { retry: false },
},
});
// In your app root
useEffect(() => {
const handler = () => {
queryClient.clear();
window.location.href = '/login';
};
window.addEventListener('auth-expired', handler);
return () => window.removeEventListener('auth-expired', handler);
}, []);
Best Practices
- Always clear the cache on logout. Use
queryClient.clear()or at minimumremoveQueriesfor user-scoped data. Stale cache between users is a security bug. - Disable queries when unauthenticated. Use the
enabledoption to prevent queries from firing before a user is logged in. - Deduplicate refresh calls. When multiple queries fail simultaneously, a shared refresh promise prevents redundant token refresh requests.
- Don't store tokens in localStorage for high-security apps. Prefer HttpOnly cookies or in-memory token storage with silent refresh. localStorage is vulnerable to XSS.
- Use
initialDataorpersistQueryClientcarefully. Persisted queries can leak data across users. Either disable persistence for sensitive data or clear it on logout. - Handle race conditions during login. If a user navigates while login is in flight, ensure pending queries are invalidated after
loginresolves so they pick up the new token. - Separate public and private query keys. Prefix private queries (e.g.,
['user', 'profile']) so you can selectively remove them without touching public data like feature flags. - Test the logout path. The most common auth bug is leftover cached data after logout. Write an integration test that logs in, fetches data, logs out, and verifies the cache is empty.
Conclusion
TanStack Query doesn't replace an authentication provider, but it gives you the right primitives to build a cohesive auth experience. By centralizing token attachment, handling refresh deduplication, clearing the cache on logout, and wiring up global 401 handling, you get a system that is both secure and pleasant to maintain. Whether you choose JWT, session cookies, or OAuth, the patterns in this tutorial scale from small SPAs to large multi-tenant applications. The key insight is to treat auth state as a first-class citizen of your data layer — when auth changes, your cache should respond immediately, and when requests fail for auth reasons, your queries should recover gracefully or hand control back to the user.