← Back to DevBytes

TanStack Query Authentication: JWT, Sessions, and OAuth Integration

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:

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

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.

🛠 Tools from DevBytes

Inventory Tracker Pro — Excel inventory system, low-stock alerts · $19
AI Dev Kit for Mac — local AI dev environment templates · $9.99
KeyMapper for Mac — custom keyboard shortcut toolkit · $7.99

← Back to all articles