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.
Taking an example into production?
Start with Before you ship: practical cards and examples for tenant isolation, delegated permissions, browser credentials, recovery, and deployment settings.
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 mandatory, so a POST without the header is a 403 before it reaches a handler.
export interface ApiError {
error: string
status?: number
message?: string
retryAfter?: number
}
const csrfStates = new Map<string, { token: string | null; request: Promise<string> | null }>()
function stateFor(baseUrl: string) {
let state = csrfStates.get(baseUrl)
if (!state) { state = { token: null, request: null }; csrfStates.set(baseUrl, state) }
return state
}
async function getCSRFToken(baseUrl: string, force = false): Promise<string> {
const state = stateFor(baseUrl)
if (force) state.token = null
if (state.token) return state.token
state.request ??= (async () => {
const res = await fetch(`${baseUrl}/auth/csrf-token`, {
credentials: "include",
cache: "no-store",
})
if (!res.ok) throw new Error("Could not initialize CSRF protection")
const payload: { token?: unknown } = await res.json()
if (typeof payload.token !== "string" || !payload.token) {
throw new Error("CSRF bootstrap returned no token")
}
state.token = payload.token
return state.token
})().finally(() => { state.request = null })
return state.request
}
const csrfErrors = new Set([
"csrf_token_missing", "csrf_token_invalid", "csrf_token_mismatch",
])
let onUnauthorized: (() => void) | null = null
/** Lets the rest of the app react the moment any request comes back with a
* dead session, instead of only the one call that hit it. Wired up in
* AuthProvider. */
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
/** Best-effort human message from anything thrown by `apiRequest` or `fetch`. */
export function errorMessage(
err: unknown,
fallback = "Something went wrong"
): string {
if (typeof err === "string" && err) return err
if (err && typeof err === "object") {
const e = err as { message?: unknown; error?: unknown }
if (typeof e.message === "string" && e.message) return e.message
if (typeof e.error === "string" && e.error) return e.error
}
return fallback
}
export async function apiRequest<T = unknown>(
baseUrl: string,
method: string,
path: string,
body?: unknown,
options: { signal?: AbortSignal; expectedRevision?: number } = {}
): Promise<T> {
const state = stateFor(baseUrl)
const headers: Record<string, string> = {}
if (body !== undefined) headers["Content-Type"] = "application/json"
if (options.expectedRevision !== undefined) {
if (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision <= 0) {
throw new Error("expectedRevision must be a positive safe integer")
}
headers["If-Match"] = `"${options.expectedRevision}"`
}
const write = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())
if (write) headers["X-CSRF-Token"] = await getCSRFToken(baseUrl)
const send = () => fetch(`${baseUrl}${path}`, {
method,
credentials: "include",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: options.signal,
cache: "no-store",
})
let res = await send()
if (write && res.status === 403) {
const error = await res.clone().json().catch(() => ({})) as { error?: string }
// These reserved codes mean middleware rejected before executing the handler.
if (error.error && csrfErrors.has(error.error)) {
headers["X-CSRF-Token"] = await getCSRFToken(baseUrl, true)
res = await send()
}
}
const replacement = res.headers.get("X-CSRF-Token")
if (replacement) state.token = replacement
if (!res.ok) {
const err: ApiError = await res
.json()
.catch(() => ({ error: res.statusText }))
err.status = res.status
if (
res.status === 401 ||
(res.status === 403 && (err.error === "user_banned" || err.error === "two_factor_required" || (path.startsWith("/admin/") && err.error === "forbidden")))
) {
onUnauthorized?.()
}
if (res.status === 429) {
err.retryAfter = Number(res.headers.get("Retry-After")) || undefined
}
throw err
}
return res.status === 204 ? (undefined as T) : res.json()
}credentials: "include" sends and stores the HttpOnly cookies. The fixed _csrf cookie is echoed through the fixed X-CSRF-Token header using the JSON bootstrap value. Cache it in memory, update it from replacement response headers, and bootstrap once after a reserved CSRF rejection. There are no application-level CSRF overrides or opt-outs. The cache above is scoped by API base URL.
This wrapper selects the ordinary app context. The admin wrapper adds X-GoAuth-Session: admin to every request and keeps its own in-memory token. Do not share one token cache between app and admin contexts. Both frontends may call the same API URL directly; the API uses independent cookie sets.
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.
Requests use cache: "no-store" so access and membership reads reach the server.
The optional fifth argument accepts an AbortSignal for cancellable reads, as
used by the org permissions client. Cancelled requests
reject; callers should ignore cancellation during cleanup. Cancelling a write
does not establish whether it committed.
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, Admin, and App roles and permissions wrap those same calls into named methods —
authApi.login(...),sessionsApi.list(...),orgApi.create(...),securityApi.changePassword(...),oauthApi.link(...),adminApi.listUsers(...),appPermissionsApi.setUserRole(...)— 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 - App roles and permissions client —
appPermissionsApi, with a shadcn example for every permission, role, grant, assignment, and access action - Org roles and permissions client —
orgPermissionsApi, with organization switching, role management, assignments, and role-linked invitations