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 travels | Authorization: Bearer <token> | httpOnly cookie set by your server |
| Can page JavaScript read it? | No, in worker mode | No |
| Needs CSRF protection? | No | Yes |
| Good for | SPAs and APIs on another domain | Apps 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
storage | Survives reload | Shared by tabs | Readable by page JS |
|---|---|---|---|
"memory" (default) | No | No | No: the token never leaves the worker |
"session" | Same tab only | No | Yes |
"local" | Yes | Yes | Yes |
"cookie" | Yes, 7 days (readable cookie, not httpOnly) | Yes | Yes |
"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.
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.
Cookie mode
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:
| Backend | xsrfCookieName | xsrfHeaderName |
|---|---|---|
| Django | csrftoken | X-CSRFToken |
| Laravel, Spring, ASP.NET Core | XSRF-TOKEN | X-XSRF-TOKEN |
| Rails | CSRF-TOKEN | X-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(); // unsubscribeThe 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.
Requests & Errors
Make GET, POST, PUT, PATCH and DELETE requests with @mrzr/api-client, build URLs and query strings, transform payloads and handle ApiError.
Token Refresh
How @mrzr/api-client refreshes access tokens automatically, sends one refresh for many concurrent 401s, and how to map custom token response shapes.