Client Integration
Setting up a frontend to call go-auth — the fetch wrapper, CSRF handling, React context, and route protection.
Client Integration
go-auth exposes an HTTP API. This guide covers how to call it from a browser frontend — the fetch wrapper, CSRF handling, session management, and React integration.
For admin dashboard integration (cross-origin, role checking), see Admin Client.
This app client uses the default session context. The admin client sends X-GoAuth-Session: admin on every request and keeps a separate CSRF cache. Both may call the same API directly without replacing each other's login. Keep Cookies.Domain empty for ordinary sibling-subdomain deployments.
File convention
Recommended file names and locations in your project:
lib/
api/
client.ts ← the fetch wrapper (apiRequest, getCSRFToken, setUnauthorizedHandler)
auth.ts ← auth API methods (register, login, logout, me)
sessions.ts ← session API methods (list, revoke, revokeAll)
security.ts ← security API methods (changePassword, forgotPassword, deleteAccount, 2FA management)
providers/
auth-provider.tsx ← React AuthProvider + useAuth() hookRoute protection
go-auth does not handle client-side route protection — that's your frontend framework's job:
- Next.js — the
rewritesinnext.config.tsproxy/api/*to the backend. For route protection, either checkuseAuth()on the client side, or add aproxy.tsthat validates the session cookie server-side. See Route protection below. - Vite / React Router — use a route guard or layout that checks
useAuth()and redirects to/loginifuseris null. - TanStack Router — use
beforeLoador a layout route with the same pattern.
Two-factor authentication
go-auth uses email codes. Non-admin 2FA is optional unless global or per-account settings require it; admin accounts require it by default. Keep challenge handling whenever an account can require 2FA. Login may also require email verification before issuing a session. See Authentication policy.
The fetch wrapper
Every API call goes through one fetch wrapper. It handles three things a plain fetch doesn't:
- Sends cookies —
credentials: "include"makes the browser send and storeHttpOnlysession cookies - Attaches CSRF header — protected browser mutations need
X-CSRF-Token; OAuth callbacks use their state cookie instead - Handles errors — parses the error response and triggers app-wide logout on
401or403 user_banned
// Use a relative API base for a frontend proxy, or the public API origin. Both use HttpOnly cookies and the same JSON CSRF bootstrap contract.
const API_BASE = "/api"
export interface ApiError {
error: string
status?: number
message?: string
retryAfter?: number
}
let csrfToken: string | null = null
let csrfRequest: Promise<string> | null = null
async function getCSRFToken(force = false): Promise<string> {
if (force) csrfToken = null
if (csrfToken) return csrfToken
csrfRequest ??= (async () => {
const res = await fetch(`${API_BASE}/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")
}
csrfToken = payload.token
return csrfToken
})().finally(() => { csrfRequest = null })
return csrfRequest
}
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>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal
): Promise<T> {
const headers: Record<string, string> = {}
if (body !== undefined) headers["Content-Type"] = "application/json"
const write = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())
if (write) headers["X-CSRF-Token"] = await getCSRFToken()
const send = () => fetch(`${API_BASE}${path}`, {
method,
credentials: "include",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
})
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(true)
res = await send()
}
}
const replacement = res.headers.get("X-CSRF-Token")
if (replacement) csrfToken = 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()
}How it works
The first write initializes protection through JSON and caches the token in memory. Cookies are HttpOnly; JavaScript never reads them. Every write sends the matching header and credentials, and response headers update the cached token after authentication changes. Concurrent first writes share bootstrap. A stale token can bootstrap and retry once for a reserved CSRF middleware error; other failures are not retried.
The contract is identical for proxied apps and sibling subdomains. Configure exact frontend origins in WithBrowser; unrelated sites also require secure SameSite=None cookies and may encounter third-party-cookie blocking. See Deployment.
Same-origin setup (recommended)
The frontend proxies API requests to the backend. Add the frontend origin to
SecurityConfig.AllowedOrigins for the browser mutation origin check, even
though the browser does not need CORS for this topology.
Next.js
// next.config.ts
const API_URL = process.env.API_URL ?? "http://localhost:8080"
const nextConfig = {
async rewrites() {
return [{ source: "/api/:path*", destination: `${API_URL}/:path*` }]
},
}
export default nextConfigconst API_BASE = "/api"Vite
// vite.config.ts
import { defineConfig } from "vite"
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
})const API_BASE = "/api"Why same-origin is simpler
| Concern | Same-origin | Cross-origin |
|---|---|---|
| CORS | Not needed | Must configure AllowedOrigins |
| CSRF token | JSON bootstrap, HttpOnly cookie | Same JSON bootstrap, HttpOnly cookie |
| Session cookie | Host-only, sent automatically | Needs SameSite=None for different domains |
| Configuration | Frontend in AllowedOrigins | Origin and cookie settings must align |
API methods
Once you have apiRequest, build named methods for each feature:
// lib/api/auth.ts
import { apiRequest } from "./client"
export interface AuthUser {
id: string
email: string
name: string
role?: "user" | "admin" // omitted with app permissions enabled
appRoleId?: string
isVerified: boolean
twoFactorEnabled: boolean
hasPassword?: boolean // included by /auth/me
}
export interface LoginOutcome {
user: AuthUser
step: "authenticated" | "verify_email" | "two_factor";
codeSent?: boolean
challengeId?: string
expiresAt?: string
}
export const authApi = {
register: (input: { email: string; password: string; name: string }) =>
apiRequest<LoginOutcome>("POST", "/auth/register", input),
login: (input: { email: string; password: string }) =>
apiRequest<LoginOutcome>("POST", "/auth/login", input),
verifyTwoFactor: (challengeId: string, code: string) =>
apiRequest<{ user: AuthUser }>("POST", "/auth/2fa/verify", { challengeId, code }),
resendTwoFactor: (challengeId: string) =>
apiRequest<{ challengeId: string; expiresAt: string; codeSent: boolean }>(
"POST", "/auth/2fa/resend", { challengeId }
),
logout: () => apiRequest("POST", "/auth/logout"),
me: () => apiRequest<AuthUser>("GET", "/auth/me"),
}// lib/api/sessions.ts
import { apiRequest } from "./client"
export const sessionsApi = {
list: (offset = 0, limit = 20) =>
apiRequest("GET", `/auth/sessions?offset=${offset}&limit=${limit}`),
listAll: () => apiRequest("GET", "/auth/sessions/all"),
revoke: (sessionId: string) =>
apiRequest("DELETE", `/auth/sessions/${sessionId}`),
revokeMany: (sessionIds: string[]) =>
apiRequest("POST", "/auth/sessions/revoke", { sessionIds }),
revokeAllExceptCurrent: () => apiRequest("DELETE", "/auth/sessions"),
}// lib/api/security.ts
import { apiRequest } from "./client"
export const securityApi = {
changeName: (name: string) => apiRequest("PUT", "/auth/name", { name }),
changePassword: (oldPassword: string, newPassword: string) =>
apiRequest("POST", "/auth/change-password", { oldPassword, newPassword }),
// Per-account email 2FA; both operations require the current password.
enableTwoFactor: (password: string, keepOtherSessions = false) =>
apiRequest("POST", "/auth/2fa/enable", { password, keepOtherSessions }),
disableTwoFactor: (password: string) =>
apiRequest("POST", "/auth/2fa/disable", { password }),
// --- Password ---
forgotPassword: (email: string) =>
apiRequest("POST", "/auth/forgot-password", { email }),
resetPassword: (code: string, newPassword: string) =>
apiRequest("POST", "/auth/reset-password", { code, newPassword }),
deleteAccount: (password: string) =>
apiRequest("DELETE", "/auth/account", { password }),
}See the full API reference in Routes for every endpoint, request/response shape, and error code.
React context
Wrap your app in an AuthProvider to keep user state in one place:
// providers/auth-provider.tsx
"use client"
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"
import { authApi, type AuthUser, type LoginOutcome } from "../lib/api/auth"
import { setUnauthorizedHandler } from "../lib/api/client"
interface TwoFactorChallenge {
challengeId: string
expiresAt: string
}
interface AuthContextValue {
user: AuthUser | null
loading: boolean
error: string | null
challenge: TwoFactorChallenge | null // null when 2FA is not enabled or not triggered
refresh: () => Promise<void>
login: (input: { email: string; password: string }) => Promise<Partial<LoginOutcome>>
register: (input: { email: string; password: string; name: string }) => Promise<Partial<LoginOutcome>>
verifyTwoFactor: (code: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null)
const refresh = useCallback(async () => {
try {
setUser(await authApi.me())
setError(null)
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
setUnauthorizedHandler(() => setUser(null))
refresh()
}, [refresh])
const login = useCallback(async (input: { email: string; password: string }) => {
setError(null)
setChallenge(null)
try {
const result = await authApi.login(input)
if (result.step === "verify_email") return result
if (result.step === "two_factor") {
setChallenge({ challengeId: result.challengeId!, expiresAt: result.expiresAt! })
return result
}
await refresh()
return result
} catch (err: any) {
setError(err.message ?? err.error)
throw err
}
}, [refresh])
const register = useCallback(async (input: { email: string; password: string; name: string }) => {
setError(null)
setChallenge(null)
try {
const result = await authApi.register(input)
if (result.step === "verify_email") return result
if (result.step === "two_factor") {
setChallenge({ challengeId: result.challengeId!, expiresAt: result.expiresAt! })
return result
}
await refresh()
return result
} catch (err: any) {
setError(err.message ?? err.error)
throw err
}
}, [refresh])
const verifyTwoFactor = useCallback(async (code: string) => {
if (!challenge) throw new Error("No active 2FA challenge")
setError(null)
try {
await authApi.verifyTwoFactor(challenge.challengeId, code)
setChallenge(null)
await refresh()
} catch (err: any) {
setError(err.message ?? err.error)
throw err
}
}, [challenge, refresh])
const logout = useCallback(async () => {
setError(null)
try {
await authApi.logout()
} catch (err: any) {
setError(err.message ?? err.error ?? "Could not sign out")
throw err
}
setUser(null)
setChallenge(null)
if (typeof window !== "undefined") window.location.href = "/login"
}, [])
return (
<AuthContext.Provider value={{ user, loading, error, challenge, refresh, login, register, verifyTwoFactor, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error("useAuth must be used within an AuthProvider")
return ctx
}Usage
// app/layout.tsx
import type { ReactNode } from "react"
import { AuthProvider } from "../providers/auth-provider"
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
)
}// components/account-menu.tsx (or any component using useAuth)
import { useAuth } from "../providers/auth-provider"
function AccountMenu() {
const { user, loading, error, logout } = useAuth()
if (loading) return <p>Loading account…</p>
if (!user) return <a href="/login">Log in</a>
return (
<div>
{user.name}
<button onClick={() => { void logout().catch(() => {}) }}>Log out</button>
{error && <p role="alert">{error}</p>}
</div>
)
}2FA login flow (optional)
When login policy requires email 2FA, login() returns { step: "two_factor", challengeId, expiresAt }. Show the emailed-code input and call verifyTwoFactor. Challenged registration uses the same state. Session-producing results call refresh(); verification-required results finish email verification, then follow its authenticated or sign_in step.
// components/login-form.tsx (or any component handling 2FA)
import { useState, type FormEvent } from "react"
import { useAuth } from "../providers/auth-provider"
function LoginForm() {
const { login, verifyTwoFactor, challenge, error } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [twoFactorCode, setTwoFactorCode] = useState("")
const [needsVerification, setNeedsVerification] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setNeedsVerification(false)
try {
const result = await login({ email, password })
setNeedsVerification(result.step === "verify_email")
} catch {
// The provider exposes the failure through error below.
}
}
const handleVerify = async (e: FormEvent) => {
e.preventDefault()
try {
await verifyTwoFactor(twoFactorCode)
} catch {
// Keep the challenge visible and display the provider error.
}
}
if (challenge) {
return (
<form onSubmit={handleVerify}>
<p>Enter the code sent to your email</p>
<input
type="text"
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value)}
placeholder="000000"
/>
{error && <p className="error">{error}</p>}
<button type="submit">Verify</button>
</form>
)
}
return (
<form onSubmit={handleSubmit}>
{needsVerification && <p>Verify your email, then sign in again.</p>}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button type="submit">Log in</button>
</form>
)
}How the provider works
- Mount-time
refresh()— callsGET /auth/meto hydrate user from existing session cookie loadingstarts astrue— gate route rendering onloading === falsesetUnauthorizedHandler— clears user state app-wide on401or403 user_banned- Gated login —
step: "verify_email"orstep: "two_factor"returns without callingrefresh() - 2FA challenge —
challengeis set whenever login policy requires it, including default admin MFA, and cleared on successful verification or logout
Route protection (Next.js proxy)
If you need server-side route protection in Next.js, add a proxy.ts. This is optional — client-side checks via useAuth() work too, but the proxy prevents the page from rendering at all for unauthenticated users.
Two approaches, trading latency for correctness:
Option 1: Cookie presence (fast, not secure)
// proxy.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
const protectedRoutes = ["/dashboard"]
const adminRoutes = ["/admin"]
export function proxy(request: NextRequest) {
const sessionToken = request.cookies.get("goauth_session")?.value
const { pathname } = request.nextUrl
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route))
const isAdmin = adminRoutes.some((route) => pathname.startsWith(route))
if ((isProtected || isAdmin) && !sessionToken) {
return NextResponse.redirect(new URL("/login", request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
}Limitations
This checks that a cookie exists, not that it's valid. An expired or forged cookie passes. It also doesn't check role — a non-admin user reaches /admin pages. Use Option 2 when validation matters.
Option 2: Validate via /auth/me and forward rotated cookies
This example assumes the same-origin /api proxy above: host-only auth cookies
belong to the frontend host and reach the Next.js request. Cookies restricted to
a separate API host are unavailable to this proxy. The /admin role guard below
assumes app permissions are disabled. Enabled mode uses /auth/access and
exact permission keys; see the app permissions client guide.
Backend actions still perform their own authorization and session-assurance checks.
// proxy.ts (validated version)
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
const API_BASE = "https://api.myapp.com"
const protectedRoutes = ["/dashboard"]
const adminRoutes = ["/admin"]
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route))
const isAdmin = adminRoutes.some((route) => pathname.startsWith(route))
if (!isProtected && !isAdmin) return NextResponse.next()
let res: Response
try {
res = await fetch(`${API_BASE}/auth/me`, {
headers: { cookie: request.headers.get("cookie") ?? "" },
cache: "no-store",
redirect: "manual",
})
} catch {
return NextResponse.redirect(new URL("/login", request.url))
}
// Preserve individual headers: Expires contains commas.
const setCookies = res.headers.getSetCookie()
const requestHeaders = new Headers(request.headers)
const cookies = new Map<string, string>()
for (const part of (requestHeaders.get("cookie") ?? "").split(";")) {
const entry = part.trim()
const separator = entry.indexOf("=")
if (separator > 0) cookies.set(entry.slice(0, separator), entry.slice(separator + 1))
}
for (const header of setCookies) {
const pair = header.split(";", 1)[0]
const separator = pair.indexOf("=")
if (separator < 1) continue
const name = pair.slice(0, separator)
const value = pair.slice(separator + 1)
if (!value || /(?:^|;)\s*max-age=0(?:;|$)/i.test(header)) cookies.delete(name)
else cookies.set(name, value)
}
requestHeaders.set("cookie", Array.from(cookies, ([name, value]) => `${name}=${value}`).join("; "))
let response: NextResponse
if (!res.ok) {
response = NextResponse.redirect(new URL("/login", request.url))
} else {
try {
const user: { role?: string } = await res.json()
response = isAdmin && user.role !== "admin"
? NextResponse.redirect(new URL("/", request.url))
: NextResponse.next({ request: { headers: requestHeaders } })
} catch {
response = NextResponse.redirect(new URL("/login", request.url))
}
}
for (const header of setCookies) response.headers.append("Set-Cookie", header)
return response
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
}Trade-off
Every matched navigation waits on the API. Fetch failures and rejected credentials redirect to /login. /auth/me can transparently rotate an expired-but-refreshable session. The example relays every raw Set-Cookie header, including on redirects, and updates the downstream request cookie header so server-rendered code sees the new tokens immediately. Use a server runtime with Headers.getSetCookie(); never comma-split these headers.
Error handling
All API errors follow the same shape:
{
"error": "invalid_credentials",
"message": "Wrong email or password"
}Handle them uniformly:
try {
await authApi.login({ email, password })
} catch (err: any) {
if (err.error === "rate_limit_exceeded") {
showError(`Too many attempts — try again in ${err.retryAfter ?? 60}s`)
return
}
showError(err.message ?? err.error)
}Rate limiting
The apiRequest wrapper attaches retryAfter (from Retry-After header) on 429 responses:
try {
await apiRequest("POST", "/auth/login", { email, password })
} catch (err: any) {
if (err.error === "rate_limit_exceeded" || err.error === "rate_limit_error") {
showError(`Too many attempts — try again in ${err.retryAfter ?? 60}s`)
return
}
throw err
}Managing email two-factor authentication
Read twoFactorEnabled from /auth/me. This per-account flag does not describe
global mandatory 2FA or default admin MFA. Changing it requires the current
password or an action-specific email confirmation. Passwordless accounts request
a challenge at /auth/2fa/settings/challenge with { action: "enable" } or
{ action: "disable" }, then submit challengeId and code to the matching
setting endpoint using the same session. With RequireEmail2FA,
changes return two_factor_already_enforced. Enabling revokes other sessions
unless keepOtherSessions: true is supplied.
// components/two-factor-settings.tsx
import { useState } from "react"
import { useAuth } from "../providers/auth-provider"
import { securityApi } from "../lib/api/security"
function TwoFactorSettings() {
const { user, refresh } = useAuth()
const [password, setPassword] = useState("")
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const changeSetting = async () => {
if (!user) return
setPending(true)
setError(null)
try {
if (user.twoFactorEnabled) await securityApi.disableTwoFactor(password)
else await securityApi.enableTwoFactor(password)
setPassword("")
await refresh()
} catch (err: any) {
setError(err.message ?? err.error ?? "Could not change email 2FA")
} finally {
setPending(false)
}
}
if (!user) return null
if (!user.hasPassword) return <p>Set a password before changing email 2FA.</p>
return (
<div>
<p>Per-account email 2FA is {user.twoFactorEnabled ? "enabled" : "disabled"}.</p>
<input type="password" autoComplete="current-password" value={password}
onChange={(event) => setPassword(event.target.value)} placeholder="Current password" />
<button disabled={pending || !password} onClick={changeSetting}>
{user.twoFactorEnabled ? "Disable" : "Enable"} email 2FA
</button>
{error && <p role="alert">{error}</p>}
</div>
)
}Future login challenges use emailed codes. There is no QR enrollment, authenticator-app secret, confirmation endpoint, or backup-code API.
Example apps coming soon
Full working example applications are on the way:
- Next.js — complete app with go-auth, middleware, and React context
- Vite + React — SPA with proxy setup and client-side routing
- TanStack Start — SSR with TanStack Router and go-auth
Next
- Admin Client — cross-origin admin dashboard setup
- Configuration — server-side config options
- Routes — every endpoint, request/response shape, and error code
Verification required on an existing session
Check /auth/me before rendering protected pages. When it returns
step: "verify_email", only id, email and isVerified: false accompany
that flag; do not treat the response as a complete user profile. Render the
verification screen, retain the session, and fetch /auth/me again after code
confirmation. Protected requests may return 403 email_not_verified if the
policy changes while the app is open; handle that response the same way.
POST /auth/refresh can also return only step: "verify_email" and
email after rotating the cookies. Treat that response as verification status,
not as a full session. Do not log out, retry the protected write automatically, or send a new email on
every render. Keep verification, resend and logout accessible.