Setup
The fetch wrapper every client-side example in these guides is built on: cookies, CSRF, and error handling.
Client
Every Client section across the guides — and every named method on the wrapped clients in this folder (authApi, and whatever gets added alongside future guides) — is built on one small fetch wrapper. It's defined once here rather than repeated in every guide.
It has to do two things a plain fetch call doesn't: send cookies on every request, and attach the CSRF header on every state-changing request. Both are mandatory — the double-submit token layer is on by default, so a POST without the header is a 403 before it reaches a handler.
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
async function getCSRFToken(baseUrl: string): Promise<string | null> {
const cookieToken = getCookie("_csrf");
if (cookieToken) return cookieToken;
const res = await fetch(`${baseUrl}/auth/csrf-token`, {
method: "GET",
credentials: "include",
});
if (!res.ok) throw new Error("Could not initialize CSRF protection");
// Same-origin and sibling-subdomain deployments receive 204 and can read
// the cookie. Different registrable domains receive { token } instead.
if (res.status === 204) return getCookie("_csrf");
const payload: { token?: unknown } = await res.json();
return typeof payload.token === "string" ? payload.token : null;
}
let onUnauthorized: (() => void) | null = null;
// Called from the Provider guide: lets the rest of the app react the moment
// any request comes back 401 or banned, instead of only the one call that hit it.
export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
export async function apiRequest(baseUrl: string, method: string, path: string, body?: unknown) {
const headers: Record<string, string> = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (method !== "GET") {
const csrf = await getCSRFToken(baseUrl);
if (csrf) headers["X-CSRF-Token"] = csrf;
}
const res = await fetch(`${baseUrl}${path}`, {
method,
credentials: "include", // required — this is how the session cookie gets sent and stored
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
if (res.status === 401 || (res.status === 403 && err.error === "user_banned")) {
onUnauthorized?.();
}
if (res.status === 429) {
err.retryAfter = Number(res.headers.get("Retry-After")) || undefined;
}
throw err;
}
return res.status === 204 ? undefined : res.json();
}credentials: "include" makes the browser send and store the HttpOnly session and refresh cookies. _csrf and X-CSRF-Token are the defaults; both are configurable on CSRFTokenConfig (see Configuration → WithSecurity). getCSRFToken reads the cookie for same-origin and sibling-subdomain deployments, and reads the { "token": "..." } body for different registrable domains. In the latter topology it calls /auth/csrf-token before each mutation, so it also picks up rotations that JavaScript cannot observe through document.cookie.
Leaving CSRFToken nil server-side does not disable the layer; the server creates the default configuration. With DisableCSRFToken: true, getCSRFToken returns null and the wrapper sends no CSRF header.
Use setUnauthorizedHandler to clear stale client state when any request finds that a session was revoked, banned, or expired. The Provider wires it to clear the app-wide user state on 401 or 403 user_banned.
On a 429, the thrown error also carries retryAfter: the Retry-After header value in seconds.
What lives where
- The individual feature guides (e.g. Authentication) show the raw call —
apiRequest(API_BASE, "POST", "/auth/login", ...)— right next to the curl and Go examples for that same endpoint, so you can see the request/response map 1:1 to what's documented above it. - Authentication, Sessions, Organizations, Security, OAuth linking, and Admin wrap those same calls into named methods —
authApi.login(...),sessionsApi.list(...),orgApi.create(...),securityApi.changePassword(...),oauthApi.link(...),adminApi.listUsers(...)— the form you'd actually want in an app. - Provider wraps those in a React context, so components read
user/loadingfrom one hook instead of calling the API directly. - Middleware is a different layer entirely — it runs on the server, before any of the above, to redirect unauthenticated requests away from protected pages.
Next
- Authentication client —
authApi, covering register, login, logout, and session checks - Sessions client —
sessionsApi, covering listing and revoking - Organizations client —
orgApi, covering org CRUD, membership, and invites - Security client —
securityApi, covering name/password changes, verification resend, and deletion - OAuth linking client —
oauthApi, covering link/unlink/list connected providers - Admin client —
adminApi, covering user management and audit-log queries