Learn TanStack - Authentication & Secure Data Fetching
Episode 12 of 24

Learn TanStack - Authentication & Secure Data Fetching

This episode secures your TanStack app: secure token storage, auth headers on the fetcher, the refresh token flow, router guards with beforeLoad, and role-based data fetching and access control.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Your queries and mutations now carry valuable data. It's time to secure them. Authentication determines who the user is, and secure data fetching determines what data each person is allowed to get. TanStack doesn't ship built-in auth — but it provides all the hooks you need to integrate it.

Episode 12 covers secure token storage, auth headers on the fetcher, the refresh token flow, router guards, and role-based data fetching. These patterns build a security layer around your queries and routes.

The core principle is simple: tokens are guarded tightly, every request carries the right credentials, and access is checked in two places — the router for navigation, and query functions for data.

Secure Token Storage

The safest way to store an access token is an HTTPOnly cookie set by the server. Cookies can't be read by JavaScript, making them immune to XSS attacks that steal tokens from localStorage:

Set cookie HTTPOnly dari server
Set-Cookie: access_token=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900

HttpOnly; Secure; SameSite=Strict makes the cookie sent only over HTTPS, never sent cross-site, and inaccessible to browser code. If you use localStorage because of backend constraints, minimize token lifetime and always validate input so XSS can't reach it.

Auth Headers on the Query Client

A Fetcher That Carries Credentials

Instead of writing the Authorization header in every query, wrap it into a single fetcher. All query functions use this fetcher:

JSFetcher dengan auth header
async function authFetch(url, options = {}) {
  const token = await getToken()
  const res = await fetch(url, {
    ...options,
    headers: {
      Authorization: `Bearer ${token}`,
      ...options.headers,
    },
  })
  if (res.status === 401) {
    await refreshToken()
    return authFetch(url, options)
  }
  return res
}
 
const { data } = useQuery({
  queryKey: ["profil"],
  queryFn: () => authFetch("/api/profil").then((res) => res.json()),
})

authFetch injects Authorization and handles 401 transparently. When the token is stale, it calls refreshToken, then retries the request. Query functions stay clean — the auth complexity is hidden in one place.

Refresh Token Flow and Session Synchronization

Renewing Tokens Without Forced Logout

A refresh token has a long lifetime and is used only to obtain new access tokens. The correct flow: the access token expires, the request returns 401, a new token is fetched, and the original request is retried:

JSMenunggu refresh token yang sedang berjalan
let refreshPromise = null
 
async function refreshToken() {
  if (!refreshPromise) {
    refreshPromise = fetch("/api/auth/refresh", {
      method: "POST",
      credentials: "include",
    }).then((res) => res.json())
  }
  return refreshPromise.finally(() => {
    refreshPromise = null
  })
}

refreshPromise holds one shared refresh process. If several requests 401 at the same time, they all wait on the same promise instead of triggering repeated refreshes — preventing a flood of requests and race conditions.

Protecting Routes with Router Guards

beforeLoad and redirect

TanStack Router protects routes through beforeLoad. If the condition isn't met, throw redirect to move the user elsewhere:

JSGuard route dengan beforeLoad
import { redirect } from "@tanstack/react-router"
 
const dashboardRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "dashboard",
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: "/login" })
    }
  },
  component: DashboardComponent,
})

context.auth comes from the router context populated at login. throw redirect({ to: "/login" }) halts navigation and moves the user. The guard can be mounted on a parent route so it protects all its children at once.

Role-based Data Fetching and Access Control

Filtering Data by Role

Auth isn't enough at the data level. Combine route guards with query functions that adapt to the user's role:

JSRole-based access control
const adminRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "admin",
  beforeLoad: ({ context }) => {
    if (context.auth.role !== "admin") {
      throw redirect({ to: "/dashboard" })
    }
  },
  component: AdminComponent,
})

The guard ensures only users with the admin role can open the admin page. On the data side, query functions use the role as part of the queryKey, and authorization is still verified by the server. Router guards only shape the experience; the server is the final gatekeeper.

Warning

Front-end guards are never enough for security. Always enforce authorization on the backend, because users can manipulate browser code. TanStack Router guards are only UX convenience.

Conclusion

Episode 12 secured your app: tokens stored in HTTPOnly cookies, authFetch carrying credentials and handling 401, refresh tokens deduplicated with a shared promise, routes guarded through beforeLoad, and access restricted per role.

Key takeaways:

  • Store tokens in an HTTPOnly cookie, not localStorage, when possible.
  • One authenticated fetcher avoids duplicating headers and 401 logic.
  • A shared promise prevents parallel refresh token runs.
  • beforeLoad with redirect protects routes from unauthenticated access.
  • Role-based guards restrict navigation per user role.
  • Front-end auth is only UX; authorization stays on the backend.

In the next episode, episode 13, we'll discuss API integration and caching strategy — REST and GraphQL integration, cache strategy with staleTime and gcTime, invalidation for real-time data, and offline support with cache persistence. Your queries will learn to talk to different backends!

Learn TanStack - Authentication & Secure Data Fetching | Learn TanStack