Skip to main content
Frontend for Backend Engineers

Auth on the Client

Ravinder··6 min read
FrontendReactTypeScriptAuthenticationSecurity
Share:
Auth on the Client

Backend engineers have strong opinions about auth — and those opinions are mostly correct. The problem is that the client environment has different threat models and different capabilities than your server. Applying server-side auth patterns naively to the browser creates security holes. Applying browser paranoia on the server side creates usability disasters.

Let's be precise about what the threat model is, what the storage options cost you, and what a correct implementation looks like.

Browser Storage Options and Their Tradeoffs

There are three places to store auth credentials in the browser:

Storage Accessible to JS Survives refresh Accessible to server XSS risk CSRF risk
localStorage Yes Yes (persistent) No (explicit header) High Low
sessionStorage Yes No (tab close) No (explicit header) High Low
httpOnly Cookie No Yes Yes (automatic) None High (mitigated by SameSite)

The "store JWT in localStorage" pattern is widespread and problematic. Any XSS vulnerability — including one in a third-party script you load — can exfiltrate the token and replay it from anywhere.

The correct answer for access tokens: httpOnly, Secure, SameSite=Strict (or Lax) cookies. JavaScript cannot read them, so XSS cannot steal them.

sequenceDiagram participant B as Browser participant S as Server B->>S: POST /auth/login {email, password} S-->>B: 200 OK + Set-Cookie: session=; HttpOnly; Secure; SameSite=Lax Note over B: Cookie stored — JS cannot read it B->>S: GET /api/profile (cookie sent automatically) S->>S: Validate cookie → identify user S-->>B: 200 OK {user data} B->>S: POST /auth/logout S-->>B: 200 OK + Set-Cookie: session=; Max-Age=0

On the client, you have no visible token. Auth state is derived from whether your API calls succeed or return 401:

function useAuth() {
  const { data: user, isLoading } = useQuery({
    queryKey: ["me"],
    queryFn: () =>
      fetch("/api/auth/me").then(async r => {
        if (r.status === 401) return null;
        if (!r.ok) throw new Error("Auth check failed");
        return r.json() as Promise<User>;
      }),
    retry: false,
    staleTime: 5 * 60 * 1000,
  });
 
  return { user: user ?? null, isLoading, isAuthenticated: !!user };
}

Token-Based Auth: When You Need It

Cookies with SameSite=Strict break cross-origin requests — legitimate for same-domain SPAs, problematic for mobile apps sharing an API or third-party integrations. In those cases, use short-lived access tokens plus long-lived refresh tokens.

The correct storage split:

  • Access token: in memory (a JavaScript variable or React state). Short TTL (15 minutes). Lost on page reload — that is intentional.
  • Refresh token: httpOnly cookie. Long TTL (7–30 days). Used only to mint new access tokens.
// AuthContext holds the in-memory access token
const AuthContext = createContext<AuthContextValue | null>(null);
 
interface AuthContextValue {
  accessToken: string | null;
  refresh: () => Promise<void>;
  logout: () => void;
}
 
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [accessToken, setAccessToken] = useState<string | null>(null);
 
  const refresh = useCallback(async () => {
    try {
      const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
      if (!res.ok) { setAccessToken(null); return; }
      const { token } = await res.json();
      setAccessToken(token);
    } catch {
      setAccessToken(null);
    }
  }, []);
 
  // Refresh on mount (silent login after reload)
  useEffect(() => { refresh(); }, [refresh]);
 
  // Refresh 1 minute before expiry (14-minute interval for 15-min tokens)
  useEffect(() => {
    if (!accessToken) return;
    const timer = setInterval(refresh, 14 * 60 * 1000);
    return () => clearInterval(timer);
  }, [accessToken, refresh]);
 
  const logout = useCallback(async () => {
    await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
    setAccessToken(null);
  }, []);
 
  return (
    <AuthContext.Provider value={{ accessToken, refresh, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

Axios/Fetch Interceptors for Token Injection

Rather than threading accessToken through every API call, use an interceptor to attach it automatically and handle 401s:

function createApiClient(getToken: () => string | null, refresh: () => Promise<void>) {
  async function request(input: RequestInfo, init?: RequestInit): Promise<Response> {
    const token = getToken();
    const headers = new Headers(init?.headers);
    if (token) headers.set("Authorization", `Bearer ${token}`);
 
    let res = await fetch(input, { ...init, headers, credentials: "include" });
 
    if (res.status === 401) {
      // Try to refresh once
      await refresh();
      const newToken = getToken();
      if (newToken) headers.set("Authorization", `Bearer ${newToken}`);
      res = await fetch(input, { ...init, headers, credentials: "include" });
    }
 
    return res;
  }
 
  return { request };
}

CSRF Mitigation

HttpOnly cookies are not read by JS but are sent automatically on cross-origin requests if SameSite=None. The standard defense is double-submit cookie or synchronizer token pattern:

// Server sets a readable (not httpOnly) CSRF token cookie
// Client reads it and sends it as a header — cross-origin requests cannot read cookies
async function csrfFetch(url: string, init: RequestInit = {}) {
  const csrfToken = document.cookie
    .split("; ")
    .find(c => c.startsWith("csrf-token="))
    ?.split("=")[1];
 
  return fetch(url, {
    ...init,
    credentials: "include",
    headers: {
      ...init.headers,
      "X-CSRF-Token": csrfToken ?? "",
    },
  });
}

With SameSite=Strict (same-site apps), CSRF is not a concern. With SameSite=Lax, top-level navigations include cookies but cross-site subresource requests do not — sufficient for most cases.

Route Protection

Route protection on the client is UX, not security. Always enforce on the server. On the client, redirect unauthenticated users to the login page:

// Next.js App Router middleware — runs on the edge, before rendering
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(req: NextRequest) {
  const session = req.cookies.get("session");
  const isAuthRoute = req.nextUrl.pathname.startsWith("/dashboard");
 
  if (isAuthRoute && !session) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
 
  return NextResponse.next();
}

Key Takeaways

  • Never store tokens in localStorage — XSS vulnerabilities in any script on your page can exfiltrate them.
  • httpOnly; Secure; SameSite=Lax cookies are the correct storage for session tokens — JavaScript cannot read them.
  • When you need bearer tokens (mobile apps, third-party integrations), keep access tokens in memory only and store refresh tokens in httpOnly cookies.
  • Implement proactive token refresh (before expiry) rather than reactive refresh (after a 401) to avoid failed requests.
  • CSRF is mitigated by SameSite cookie policy for same-site apps; cross-site apps need double-submit cookie or a CSRF header pattern.
  • Client-side route guards are UX conveniences — authorization must be enforced server-side, always.
Share: