@mrzr/api-client

Authentication

Log users in with bearer tokens or httpOnly cookies. Choose where tokens are stored, restore a cookie session on page load and enable CSRF protection.

The client owns the tokens. You never set an Authorization header or read storage yourself.

Two modes

authMode: "header" (default)authMode: "cookie"
How the credential travelsAuthorization: Bearer <token>httpOnly cookie set by your server
Can page JavaScript read it?No, in worker modeNo
Needs CSRF protection?NoYes
Good forSPAs and APIs on another domainApps where you control the backend

Header mode

const api = createClient({ baseUrl: "https://api.example.com" });

await api.login({ email, password });   // tokens captured from the response
await api.get("/me");                   // Authorization: Bearer … attached
await api.logout();

The token is only ever sent to the baseUrl origin and the page's own origin. To send it to another API of yours, list that origin:

createClient({ baseUrl, authOrigins: ["https://files.example.com"] });

For several APIs, the services plugin does this for you.

Where tokens are stored

storageSurvives reloadShared by tabsReadable by page JS
"memory" (default)NoNoNo: the token never leaves the worker
"session"Same tab onlyNoYes
"local"YesYesYes
"cookie"Yes, 7 days (readable cookie, not httpOnly)YesYes

"memory" is the safest option, but the user logs in again after a reload. To stay signed in across reloads without a readable token, use cookie mode.

Keys are prefixed with storageKey (default "apiclient"), so the record lives at apiclient.tokens. "cookie" is meant for SSR that needs to read the token. A large token pair is split across several cookies automatically; past about 19 KB the adapter writes nothing and warns, so use "local" for tokens that big. Storage applies to header mode only.

Your own storage

Any object with get, set and clear works. Each may be sync or async, and worker mode stays on: the adapter runs on the page and the worker writes through it. Errors thrown by an adapter are swallowed, so broken storage never breaks a request.

React Native
import AsyncStorage from "@react-native-async-storage/async-storage";

createClient({
  baseUrl,
  worker: false,
  multiTab: false,
  storage: {
    get: async () => JSON.parse((await AsyncStorage.getItem("auth")) ?? "null"),
    set: (tokens) => AsyncStorage.setItem("auth", JSON.stringify(tokens)),
    clear: () => AsyncStorage.removeItem("auth"),
  },
});

The same shape fits Expo SecureStore, IndexedDB (idb-keyval) or anything else. The built-in adapters are exported, so you can compose them:

import { MemoryStorage, WebStorage, CookieStorage, type TokenPair, type TokenStorage } from "@mrzr/api-client";

new WebStorage("app.tokens", "session");   // or "local"
new CookieStorage("app.tokens", 30);       // 30-day cookie

// sessionStorage, falling back to memory where storage is blocked
class Fallback implements TokenStorage {
  private mem = new MemoryStorage();
  private web = new WebStorage("app.tokens", "session");
  get() { return this.web.get() ?? this.mem.get(); }
  set(t: TokenPair) { this.web.set(t); this.mem.set(t); }
  clear() { this.web.clear(); this.mem.clear(); }
}

Logging in and out

login(body)

login posts body to loginUrl (default /auth/login) and stores the tokens it finds in the response. It recognises access, accessToken, access_token, token, jwt and idToken, with refresh, refreshToken or refresh_token, at the top level or inside data, tokens, result or payload. The expiry comes from expiresAt, expires_at, expiry, expiresIn or expires_in, and otherwise from the JWT's exp claim. Other field names? See custom token shapes.

A user object in the response is kept in the auth state. login follows throwError like any request, so wrong credentials throw:

try {
  await api.login({ email, password });
  router.push("/dashboard");
} catch (e) {
  if (e instanceof ApiError && e.statusCode === 401) setError("Wrong email or password");
}

logout()

logout posts the refresh token to logoutUrl, then clears the tokens in every tab, whether or not the request succeeded. It never throws: being logged out locally matters more than the round trip.

setTokens(tokens)

Seed tokens you got elsewhere: an OAuth callback, SSR, or your own login flow.

await api.setTokens({ accessToken, refreshToken, expiresAt });   // expiresAt is optional

// OAuth implicit flow
const hash = new URLSearchParams(location.hash.slice(1));
await api.setTokens({ accessToken: hash.get("access_token")! });
history.replaceState(null, "", location.pathname);

Only the keys you pass change, so leaving out refreshToken keeps the existing one. Pass a key as undefined to clear it. setTokens({ accessToken: undefined, refreshToken: undefined }) logs out locally without calling the server.

Custom endpoints

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, for auth on another host.

Your server sets HttpOnly; Secure cookies at login. The client never sees a token. It just sends requests with credentials: "include".

const api = createClient({
  baseUrl: "https://api.example.com",
  authMode: "cookie",
  xsrfCookieName: "csrftoken",   // see CSRF below
});

For a cross-origin API the server must answer with Access-Control-Allow-Credentials: true and an explicit origin (never *), and the cookie needs SameSite=None; Secure.

JavaScript can't see httpOnly cookies, so after a page reload the client doesn't know whether a session exists. Ask the server once on startup:

const { isAuthenticated, user } = await api.restoreSession("/auth/me");

user is the response's user field, or the whole body if there isn't one. Without a URL, restoreSession() tries the refresh endpoint instead. In header mode it makes no request, so it's safe to call either way.

In cookie mode the client only trusts answers that prove a session: a successful login, refresh or restoreSession sets isAuthenticated. A plain 200 from some endpoint doesn't, because public endpoints answer anonymous visitors too. The session ends when the refresh endpoint rejects it (401/403) or on logout.

CSRF

With cookies, a malicious site can make the browser send your cookie along with a forged request. The standard defence is the double-submit pattern: the server sets a readable CSRF cookie, and the client copies its value into a header.

createClient({ authMode: "cookie", xsrfCookieName: "csrftoken" });
// POST/PUT/PATCH/DELETE and refresh → X-CSRF-Token: <cookie value>

The header only goes on writes and the refresh request, only to trusted origins, and never replaces one you set yourself on a request. Match your backend's names:

BackendxsrfCookieNamexsrfHeaderName
DjangocsrftokenX-CSRFToken
Laravel, Spring, ASP.NET CoreXSRF-TOKENX-XSRF-TOKEN
RailsCSRF-TOKENX-CSRF-Token (the default)

If the token isn't in a cookie, for example it's in a <meta> tag, supply it yourself. getCsrfToken takes precedence over the cookie and may be async. It runs on the page, so it works in worker mode too:

createClient({
  authMode: "cookie",
  getCsrfToken: () => document.querySelector<HTMLMetaElement>("meta[name=csrf-token]")?.content,
});

Some backends set the cookie only after a first request. Laravel Sanctum, for example: await api.get("/sanctum/csrf-cookie") before logging in. Your server must still compare the header with the cookie and reject mismatches.

Auth state

const state = await api.getAuthState();   // { isAuthenticated, expiresAt, user }
const off = api.onAuthStateChange((state) => { /* … */ });
off();                                    // unsubscribe

The state never contains tokens. It changes on login, logout, refresh, setTokens and when another tab changes the session. expiresAt is null for an opaque (non-JWT) token, which counts as authenticated until the server says otherwise.

Two client options report the same events: onAuthStateChanged(state) for every change, and onAuthFailure() only when the session is gone for good: the refresh endpoint rejected it (401/403), or the user logged out in any tab. That's the place to clear caches and redirect.

getAuthState() waits for stored tokens to load, so use it to gate your UI and avoid flashing the login page on reload:

export function useAuth() {
  const [state, setState] = useState<AuthState | null>(null);   // null = not known yet
  useEffect(() => {
    api.getAuthState().then(setState);
    return api.onAuthStateChange(setState);
  }, []);
  return state;
}

user is typed unknown: it's whatever your server returns. See TypeScript to narrow it.

On this page