Authentication
Header mode, cookie mode, login/logout, and auth state management
The client owns your tokens. You never attach an Authorization header, never read storage, never pass a token into a request.
Two modes
authMode: "header" (default) | authMode: "cookie" | |
|---|---|---|
| Credential | Authorization: Bearer <token> | httpOnly cookie set by your server |
credentials | "same-origin" | "include" |
| Client sees the token | Yes | No |
| XSS can steal the token | Only if storage is readable — see [[Storage Adapters]] | No |
| CSRF risk | None (headers aren't sent cross-site automatically) | Yes → use [[CSRF Protection]] |
| Cross-origin setup | Simple | Needs SameSite=None; Secure + CORS Access-Control-Allow-Credentials |
| Best for | SPAs, mobile web, third-party APIs | Same-site apps where you control the backend |
Header mode
const api = createClient({
baseUrl: "https://api.example.com",
authMode: "header", // the default
storage: "memory", // "memory" | "local" | "session" | "cookie"
loginUrl: "/auth/login",
refreshUrl: "/auth/refresh",
logoutUrl: "/auth/logout",
});
await api.login({ email: "a@b.com", password: "secret" });
const me = await api.get<User>("/me"); // Authorization attached automatically
await api.logout();Cookie mode
Your server sets HttpOnly; Secure; SameSite cookies. The client never sees a token — it just sends credentials: "include".
const api = createClient({
baseUrl: "https://api.example.com",
authMode: "cookie",
xsrfCookieName: "csrftoken", // strongly recommended — see CSRF Protection
});
await api.login({ email, password }); // server sets the cookies
const me = await api.get<User>("/me");In cookie mode:
- No local storage adapter is created — there's nothing to persist.
- Proactive refresh is skipped (the client can't read the token's expiry), so refresh is driven by 401s.
- A refresh response with no body still counts as success: the server rotated the cookies.
Server-side requirements for cross-origin cookie auth:
Set-Cookie: access=…; HttpOnly; Secure; SameSite=None; Path=/
Access-Control-Allow-Origin: https://app.example.com (not *)
Access-Control-Allow-Credentials: truelogin()
const res = await api.login<LoginPayload>({ email, password });What it does:
POSTtologinUrlwithskipAuth: true,refreshTokenCheck: false,fullData: true.- Runs
extractTokensover the full response body and stores whatever it finds. - Runs the user extractor (
userat the top level, or underdata/result/payload) and stores it inAuthState. - Broadcasts
loginto other tabs. - Re-applies your unwrapping preference to the returned
data.
The default extractor understands a lot of shapes out of the box:
{ "access": "…", "refresh": "…" }
{ "access_token": "…", "refresh_token": "…" }
{ "accessToken": "…", "refreshToken": "…" }
{ "token": "…" }
{ "jwt": "…" }
{ "idToken": "…" }
// …and all of the above nested under "data", "tokens", "result" or "payload"Expiry is read from expiresAt, expires_at, expiresIn, expires_in or expiry — durations in seconds are converted to absolute timestamps, and second-vs-millisecond timestamps are disambiguated. Failing that, it is decoded from the JWT exp claim.
Custom shape? See [[Token Refresh]] for extractTokens.
login()obeysthrowErrorlike any other request — bad credentials reject with anApiErrorcarrying401and the server's message.
try {
await api.login({ email, password });
router.push("/dashboard");
} catch (e) {
if (e instanceof ApiError && e.statusCode === 401) {
setError("Wrong email or password");
}
}logout()
await api.logout();POSTtologoutUrlwith the refresh body,refreshTokenCheck: false,hideErrorMessage: true.- Clears tokens locally whether or not the request succeeded.
- Broadcasts
logoutto every other tab, which clear immediately.
Logging out locally matters more than the round trip, so logout() never throws — a network failure still logs you out.
Local-only logout (no server call):
await api.setTokens({ accessToken: undefined, refreshToken: undefined });setTokens() — seeding from elsewhere
For SSR hydration, an OAuth callback, or a login flow you implemented yourself:
await api.setTokens({
accessToken,
refreshToken,
expiresAt: Date.now() + 3600_000, // optional; derived from the JWT if omitted
});Only the keys you pass are updated — omit refreshToken and the existing one is kept. This broadcasts login to other tabs.
OAuth callback:
const params = new URLSearchParams(location.hash.slice(1));
await api.setTokens({
accessToken: params.get("access_token")!,
refreshToken: params.get("refresh_token") ?? undefined,
});
history.replaceState(null, "", location.pathname);Next.js SSR hydration:
// server component
const token = (await cookies()).get("access_token")?.value;
return <Providers token={token}>{children}</Providers>;
// client provider
useEffect(() => {
if (token) void api.setTokens({ accessToken: token });
}, [token]);Auth state
interface AuthState {
isAuthenticated: boolean; // header mode: a non-expired access token
// cookie mode: a session the server confirmed
expiresAt: number | null; // epoch ms, or null for opaque tokens
user?: unknown; // whatever login/refresh returned as `user`
}Never contains tokens. That is the invariant that makes worker isolation meaningful.
const state = await api.getAuthState();
const stop = api.onAuthStateChange((state) => {
if (!state.isAuthenticated) router.push("/login");
});
stop(); // unsubscribeonAuthStateChange fires on login, logout, refresh, setTokens, setUser, and when another tab changes state. Listener exceptions are caught, so one bad subscriber can't break auth.
isAuthenticatedistruefor an opaque (non-JWT) token, because the client can't know its expiry. It lets the server decide via a 401.
Cookie mode: knowing whether you're logged in
With authMode: "cookie" the tokens are httpOnly, so JavaScript can never read them. There is nothing on the client that says "logged in" — the cookie is invisible to document.cookie by design.
The client therefore tracks the session from what the server actually does:
| Event | isAuthenticated |
|---|---|
login() returns 2xx | true |
| any authenticated request returns 2xx | true |
| a refresh succeeds | true |
| a request 401/403s after the retry flow | false |
logout() | false |
| a fresh page load | false until you ask the server |
That last row is the important one. After a reload the cookie is still in the browser and requests will succeed — but the client has no way to know that yet. Ask explicitly, once, on startup:
// Populates `user` too, if the endpoint returns one.
const state = await api.restoreSession("/api/auth/me");
if (state.isAuthenticated) {
// already signed in
}Omit the URL to probe the refresh endpoint instead:
await api.restoreSession();In header mode restoreSession() makes no request — the stored token already answers the question — so it is safe to call unconditionally.
Multi-tab sync works in cookie mode too: the cookie is shared across tabs by the browser, so a login, refresh or logout in one tab updates the others. See [[Multi-Tab Sync]].
Before v1.0.2
isAuthenticatedwas derived purely from a readable access token, so in cookie mode it was permanentlyfalse, even immediately after a successful login.
onAuthFailure
Fires when auth is permanently lost — refresh rejected, logout, or another tab logging out. This is your redirect hook.
createClient({
onAuthFailure: () => {
queryClient.clear();
router.push("/login?reason=expired");
},
});onAuthStateChange reports every transition; onAuthFailure reports only the terminal one.
A React auth provider
import { createContext, useContext, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { AuthState } from "@mrzr/api-client";
const AuthCtx = createContext<AuthState & { ready: boolean }>({
isAuthenticated: false, expiresAt: null, ready: false,
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ isAuthenticated: false, expiresAt: null });
const [ready, setReady] = useState(false);
useEffect(() => {
let alive = true;
void api.getAuthState().then((s) => {
if (alive) { setState(s); setReady(true); }
});
const stop = api.onAuthStateChange(setState);
return () => { alive = false; stop(); };
}, []);
return <AuthCtx.Provider value={{ ...state, ready }}>{children}</AuthCtx.Provider>;
}
export const useAuth = () => useContext(AuthCtx);The ready flag matters: getAuthState() awaits storage hydration, so before it resolves you don't yet know whether the user is signed in. Render a splash screen until then, or you'll flash the login page on every reload.
A Vue composable
import { ref, onUnmounted } from "vue";
import { api } from "@/lib/api";
export function useAuth() {
const state = ref({ isAuthenticated: false, expiresAt: null as number | null });
const ready = ref(false);
void api.getAuthState().then((s) => { state.value = s; ready.value = true; });
const stop = api.onAuthStateChange((s) => { state.value = s; });
onUnmounted(stop);
return { state, ready };
}Custom endpoint paths
createClient({
loginUrl: "/api/v1/auth/token/",
refreshUrl: "/api/v1/auth/token/refresh/",
logoutUrl: "/api/v1/auth/token/blacklist/",
});Paths are joined onto baseUrl. Absolute URLs work too, if auth lives on a different host.
Next: [[Token Refresh]]