go-auth

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() hook

Route protection

go-auth does not handle client-side route protection — that's your frontend framework's job:

  • Next.js — the rewrites in next.config.ts proxy /api/* to the backend. For route protection, either check useAuth() on the client side, or add a proxy.ts that 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 /login if user is null.
  • TanStack Router — use beforeLoad or 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:

  1. Sends cookies — credentials: "include" makes the browser send and store HttpOnly session cookies
  2. Attaches CSRF header — protected browser mutations need X-CSRF-Token; OAuth callbacks use their state cookie instead
  3. Handles errors — parses the error response and triggers app-wide logout on 401 or 403 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.

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 nextConfig
const 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

ConcernSame-originCross-origin
CORSNot neededMust configure AllowedOrigins
CSRF tokenJSON bootstrap, HttpOnly cookieSame JSON bootstrap, HttpOnly cookie
Session cookieHost-only, sent automaticallyNeeds SameSite=None for different domains
ConfigurationFrontend in AllowedOriginsOrigin 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() — calls GET /auth/me to hydrate user from existing session cookie
  • loading starts as true — gate route rendering on loading === false
  • setUnauthorizedHandler — clears user state app-wide on 401 or 403 user_banned
  • Gated login — step: "verify_email" or step: "two_factor" returns without calling refresh()
  • 2FA challenge — challenge is 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:

// 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.

On this page