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
zustandwithpersistmiddlewareky(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().tokeninbeforeRequesthook - Calls
useAuth.getState().clearAuth()on 401 responses - Sets
credentials: 'include'whenSTORAGE_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...
};
}4. Bootstrap on app init (cookie mode)
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
| Mode | Use Case | Security |
|---|---|---|
localStorage | Default. Simple, survives refresh. | XSS-vulnerable |
sessionStorage | Tab-scoped auth. | XSS-vulnerable |
memory | Most secure client-side. Lost on refresh. | Requires refresh token flow |
cookie | Production recommended. httpOnly cookie. | Server-managed, XSS-safe |
Set via environment variable:
bash
VITE_AUTH_STORAGE_MODE=cookieVariations
- Minimal auth (
- With access menu (