Skip to content

Auth Flow

A complete authentication stack with configurable storage modes, automatic token injection, and proactive token refresh.

What Problem This Solves

You need end-to-end auth that:

  • Supports multiple token storage strategies (localStorage, sessionStorage, memory, httpOnly cookie)
  • Automatically injects Bearer tokens into API requests
  • Handles 401 unauthorized responses by clearing auth and redirecting
  • Supports proactive token refresh before expiry
  • Works with both client-managed tokens and server-managed httpOnly cookies

Prerequisites

  • zustand with persist middleware
  • ky (or any HTTP client with hooks)
  • @tanstack/react-router

Files

auth.ts (Zustand Store)

ts
import { create, type StateCreator } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { useShallow } from "zustand/shallow";

export type AuthStorageMode = "localStorage" | "sessionStorage" | "memory" | "cookie";

export const STORAGE_MODE: AuthStorageMode =
  (import.meta.env.VITE_AUTH_STORAGE_MODE as AuthStorageMode) || "localStorage";

const DEFAULT_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
const REFRESH_BUFFER_MS = 5 * 60 * 1000;

export const authSessionKey = "YXV0aF9zZXNzaW9u"; // base64("auth_session")

export type AuthUser = {
  id: string;
  email: string;
  name: string;
};

export type AuthSession = {
  token: string;
  user: AuthUser;
  expiresAt?: string;
  refreshToken?: string;
};

export type AuthStoreState = {
  token?: string;
  user?: AuthUser;
  expiresAt?: string;
  isAuthenticated: boolean;
};

export const INITIAL_AUTH_STORE_VALUE: AuthStoreState = {
  token: undefined,
  user: undefined,
  expiresAt: undefined,
  isAuthenticated: false,
};

export const isTokenExpired = (expiresAt?: string): boolean => {
  if (!expiresAt) return false;
  return new Date(expiresAt) < new Date();
};

export const isTokenExpiringSoon = (expiresAt?: string): boolean => {
  if (!expiresAt) return false;
  return new Date(expiresAt).getTime() - Date.now() <= REFRESH_BUFFER_MS;
};

type AuthStoreActions = {
  setSession: (session: AuthSession) => void;
  setToken: (token: string) => void;
  clearAuth: () => void;
  refreshSession: (newSession: AuthSession) => void;
  shouldRefreshToken: () => boolean;
};

type AuthStore = AuthStoreState & AuthStoreActions;

const getAuthStorage = () => {
  switch (STORAGE_MODE) {
    case "sessionStorage":
      return createJSONStorage(() => sessionStorage);
    case "memory": {
      const memoryStore = new Map<string, string>();
      return createJSONStorage(() => ({
        getItem: (key: string) => memoryStore.get(key) ?? null,
        setItem: (key: string, value: string) => memoryStore.set(key, value),
        removeItem: (key: string) => memoryStore.delete(key),
      }));
    }
    default:
      return createJSONStorage(() => localStorage);
  }
};

const authStoreInitializer: StateCreator<AuthStore> = (set, get) => ({
  ...INITIAL_AUTH_STORE_VALUE,
  setSession: (session) =>
    set({
      token: session.token,
      user: session.user,
      expiresAt: session.expiresAt ?? new Date(Date.now() + DEFAULT_TOKEN_EXPIRY_MS).toISOString(),
      isAuthenticated: true,
    }),
  setToken: (token) => set({ token }),
  clearAuth: () => set(INITIAL_AUTH_STORE_VALUE),
  refreshSession: (newSession) =>
    set({
      token: newSession.token,
      expiresAt: newSession.expiresAt,
      isAuthenticated: true,
    }),
  shouldRefreshToken: () => {
    const state = get();
    return isTokenExpiringSoon(state.expiresAt) && !!state.token;
  },
});

const useAuth =
  STORAGE_MODE === "cookie"
    ? create<AuthStore>()(authStoreInitializer)
    : create<AuthStore>()(
        persist(authStoreInitializer, {
          name: authSessionKey,
          storage: getAuthStorage(),
        }),
      );

export { useAuth };

export const useAuthState = (): AuthStoreState =>
  useAuth(
    useShallow(({ token, user, expiresAt, isAuthenticated }) => ({
      token,
      user,
      expiresAt,
      isAuthenticated,
    })),
  );

export const useAuthAction = (): AuthStoreActions =>
  useAuth(
    useShallow(({ setSession, setToken, clearAuth, refreshSession, shouldRefreshToken }) => ({
      setSession,
      setToken,
      clearAuth,
      refreshSession,
      shouldRefreshToken,
    })),
  );

export const shouldRefreshToken = (): boolean => {
  const state = useAuth.getState();
  return isTokenExpiringSoon(state.expiresAt) && !!state.token;
};

export const bootstrapAuth = async ({
  fetchUser,
}: {
  fetchUser: () => Promise<{ user: AuthUser; expiresAt?: string }>;
}): Promise<void> => {
  try {
    const { user, expiresAt } = await fetchUser();
    useAuth.getState().setSession({ token: "", user, expiresAt });
  } catch {
    useAuth.getState().clearAuth();
  }
};

fetcher.ts (API Client with Auth)

See API Client pattern for the complete fetcher setup.

Key integration points:

  • Reads useAuth.getState().token in beforeRequest hook
  • Calls useAuth.getState().clearAuth() on 401 responses
  • Sets credentials: 'include' when STORAGE_MODE === 'cookie'

Usage

1. Provide auth state to router context

tsx
// routes/__root.tsx
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { useAuth, useAuthState } from "@/lib/stores/auth";

interface RouterContext {
  auth: ReturnType<typeof useAuthState>;
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootComponent,
  beforeLoad: () => {
    return { auth: useAuth.getState() };
  },
});

function RootComponent() {
  const auth = useAuthState();
  return <Outlet context={{ auth }} />;
}

2. Protect private routes

See Route Guards.

3. Login form

tsx
import { useAuthAction } from "@/lib/stores/auth";

export function LoginForm() {
  const { setSession } = useAuthAction();

  const handleSubmit = async (values: LoginInput) => {
    const session = await authAdapter.login(values);
    setSession(session);
    // redirect...
  };
}
tsx
import { bootstrapAuth } from "@/lib/stores/auth";

useEffect(() => {
  if (STORAGE_MODE === "cookie") {
    bootstrapAuth({
      fetchUser: async () => {
        const res = await fetch("/auth/me", { credentials: "include" });
        if (!res.ok) throw new Error("Not authenticated");
        return res.json();
      },
    });
  }
}, []);

Storage Modes

ModeUse CaseSecurity
localStorageDefault. Simple, survives refresh.XSS-vulnerable
sessionStorageTab-scoped auth.XSS-vulnerable
memoryMost secure client-side. Lost on refresh.Requires refresh token flow
cookieProduction recommended. httpOnly cookie.Server-managed, XSS-safe

Set via environment variable:

bash
VITE_AUTH_STORAGE_MODE=cookie

Variations

  • Minimal auth (
  • With access menu (