Agora

Typed Client & TanStack Query

useResource, createAuthkitClient, AuthkitClientProvider, query/mutation hooks, query keys, and error handling for the admin and account APIs.

@adonis-agora/authkit-react ships a typed HTTP client and a full set of TanStack Query hooks for both the admin console API and the account self-service API. This lets your React UI fetch and mutate data with full TypeScript inference — no hand-written fetch calls, no manual cache invalidation boilerplate.

The login and consent flows remain server-side postback for security. The client/hooks layer is for management screens (admin UIs, account pages) that need rich, reactive data.

┌────────────────────────────────┐
│  createAuthkitClient(opts?)    │  ← low-level typed fetch wrapper
│    client.admin.*              │
│    client.account.*            │
└──────────────┬─────────────────┘
               │  wrapped by
┌──────────────▼─────────────────┐
│  TanStack Query hooks          │  ← useUsersQueryOptions(), useMutation(…)
│  authkitKeys.*                 │  ← structured cache keys
└────────────────────────────────┘

Install

@tanstack/react-query is a peer dependency. Install it alongside the package:

pnpm add @adonis-agora/authkit-react @tanstack/react-query

Building your own hook with useResource

Before the typed client, there is a smaller escape hatch. Every headless hook in @adonis-agora/authkit-reactuseProfile, useSessions, useOrganizations, and the rest — is useResource plus a couple of actions built on jsonRequest. Both are exported, so a screen that needs an endpoint AuthKit knows nothing about does not have to switch fetching styles (or pull in React Query for one list).

function useResource<T>(url: string, csrfToken?: string): {
  data: T | null
  loading: boolean
  error: Error | null
  refetch: () => Promise<void>
}

function jsonRequest<T>(url: string, init?: RequestInit & { csrfToken?: string }): Promise<T>

jsonRequest is the one-shot primitive: it always sends credentials: 'same-origin' and Accept: application/json, adds Content-Type: application/json when there is a body and X-CSRF-TOKEN when you pass csrfToken, throws an Error on any non-2xx response (using the server's message field when the error body has one), and resolves to null on an empty 204. useResource wraps it in a GET-on-mount effect: it only fetches inside the effect, so it is SSR-safe, and it refetches whenever the URL or the token changes.

Building your own hook is then mostly naming things:

app/hooks/use_api_keys.ts
import { useCallback } from 'react'
import { jsonRequest, useResource, useAuthkitConfig } from '@adonis-agora/authkit-react'

interface ApiKey {
  id: string
  label: string
  createdAt: string
}

export function useApiKeys() {
  const { csrfToken } = useAuthkitConfig()
  const { data, loading, error, refetch } = useResource<ApiKey[]>('/api/keys', csrfToken)

  const revoke = useCallback(
    async (id: string) => {
      await jsonRequest(`/api/keys/${encodeURIComponent(id)}`, {
        method: 'DELETE',
        csrfToken,
      })
      await refetch()
    },
    [csrfToken, refetch],
  )

  return { data, loading, error, actions: { refetch, revoke } }
}

Two details worth copying from the built-in hooks: read csrfToken from useAuthkitConfig() instead of threading it through props, and return mutations under an actions object so the hook keeps the { data, loading, error, actions } shape the rest of the kit uses. When a mutation needs to be reflected in the UI, await refetch() after it — there is no cache to invalidate at this layer, which is exactly the trade-off you accept for not needing a QueryClient.

Reach for the typed client and the TanStack hooks below when a screen has many interlocking queries, needs shared caching across components, or wants optimistic updates. Reach for useResource when a screen needs one list and a refresh.

Provider setup

Wrap your app (or the subtree that needs data access) once:

app/layouts/admin_layout.tsx
import { QueryClientProvider } from '@tanstack/react-query'
import {
  createAuthkitQueryClient,
  AuthkitClientProvider,
} from '@adonis-agora/authkit-react'

const queryClient = createAuthkitQueryClient()

export default function AdminLayout({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthkitClientProvider>
        {children}
      </AuthkitClientProvider>
    </QueryClientProvider>
  )
}

createAuthkitQueryClient() creates a QueryClient pre-tuned for AuthKit:

DefaultValue
staleTime30 s
gcTime5 min
retry1
refetchOnWindowFocusfalse

You can bring your own QueryClient — the helper is purely optional.

AuthkitClientProvider reads window.__AUTHKIT__ (injected by the admin shell) to configure the base URLs and CSRF token automatically. In SSR or tests, pass an explicit client:

import { createAuthkitClient, AuthkitClientProvider } from '@adonis-agora/authkit-react'

const client = createAuthkitClient({ baseUrl: '/admin/api', csrfToken: 'test-token' })

<AuthkitClientProvider client={client}>
  <App />
</AuthkitClientProvider>

The typed client

createAuthkitClient(opts?) returns a typed wrapper around fetch. It uses window.__AUTHKIT__ for URLs and CSRF by default.

import { createAuthkitClient } from '@adonis-agora/authkit-react'

// Browser — reads window.__AUTHKIT__ automatically
const client = createAuthkitClient()

// Explicit (SSR, tests, custom topologies)
const client = createAuthkitClient({
  baseUrl: '/admin/api',       // default: window.__AUTHKIT__.endpoints.api
  accountBaseUrl: '/account/api', // default: '/account/api'
  csrfToken: 'my-token',
  fetch: customFetch,          // optional — inject a mock in tests
})

Admin surface — client.admin.*

MethodHTTPNotes
admin.overview()GET /overviewDashboard metrics
admin.users.list(params?)GET /users{ search?, page?, size? }{ meta, data }
admin.users.get(id)GET /users/:idSingle user
admin.users.create(data)POST /usersCreates or invites
admin.users.update(id, data)PATCH /users/:idPartial update
admin.users.disable(id)POST /users/:id/disableBlocks login
admin.users.enable(id)POST /users/:id/enableRe-enables account
admin.users.resetPassword(id)POST /users/:id/reset-passwordSends email
admin.users.remove(id)DELETE /users/:idCascade delete
admin.users.getSessions(id)GET /users/:id/sessionsActive sessions
admin.users.revokeSessions(id)POST /users/:id/revoke-sessionsRevoke all
admin.sessions.list(accountId?)GET /sessionsAll active sessions
admin.sessions.revokeAll(accountId?)POST /sessions/revoke-all
admin.clients.list()GET /clients
admin.clients.get(id)GET /clients/:id
admin.clients.create(data?)POST /clients
admin.clients.update(id, data?)PATCH /clients/:id
admin.clients.remove(id)DELETE /clients/:id
admin.clients.regenerateSecret(id)POST /clients/:id/regenerate-secretShown once
admin.roles.list()GET /roles
admin.roles.create(data)POST /roles
admin.roles.update(name, data)PATCH /roles/:name
admin.roles.remove(name)DELETE /roles/:name
admin.orgs.list()GET /orgs
admin.orgs.get(id)GET /orgs/:id
admin.orgs.create(data)POST /orgs
admin.orgs.update(id, data)PATCH /orgs/:id
admin.orgs.remove(id)DELETE /orgs/:id
admin.orgs.addMember(orgId, { accountId, role })POST /orgs/:id/membersAdds an existing account to the org
admin.orgs.removeMember(orgId, accountId)DELETE /orgs/:id/members/:accountId
admin.orgs.updateMemberRole(orgId, accountId, role)PATCH /orgs/:id/members/:accountIdSends { role }
admin.orgs.createInvitation(orgId, { email, role })POST /orgs/:id/invitationsFor someone who has no account yet
admin.orgs.revokeInvitation(orgId, invitationId)DELETE /orgs/:id/invitations/:invitationId
admin.audit.list(params?)GET /audit{ type?, page?, size?, subject? }{ meta, data }
admin.settings.list(orgId?)GET /settingsorgId scopes to one org; omitted = global
admin.settings.set(key, value, orgId?)PUT /settings/:keySame scoping
admin.settings.remove(key, orgId?)DELETE /settings/:keyResets to the library default
admin.impersonation.get(userId)GET /impersonation/:userIdPanel data
admin.keys.status()GET /keysSigning-key age, policy, next-rotation ETA
admin.keys.rotate(input?)POST /keys/rotate{ retire?, keep? }

All methods under admin.* hit {adminBase}/api/* — the prefix is derived from window.__AUTHKIT__.endpoints.api or opts.baseUrl.

Account surface — client.account.*

MethodHTTPNotes
account.me()GET /account/api/meProfile + capability flags + sudo state
account.security()GET /account/api/securitySessions, MFA state, pending email
account.updateProfile(data)PATCH /account/api/profileName / avatar URL
account.changePassword(data)POST /account/api/passwordRequires sudo + current pw
account.emailChange(data)POST /account/api/email-changeRequires sudo
account.cancelEmailChange()POST /account/api/email-change/cancel
account.sessions.list()GET /account/api/sessions
account.sessions.revoke(id)DELETE /account/api/sessions/:id
account.sessions.revokeOthers()POST /account/api/sessions/revoke-others
account.sessions.revokeAll()POST /account/api/sessions/revoke-allSign out of all devices, including the current one
account.apps.list()GET /account/api/appsAuthorized OAuth grants
account.apps.revoke(clientId)DELETE /account/api/apps/:clientId
account.mfa()GET /account/api/mfaTOTP + passkeys + recovery status
account.passkeys.list()GET /account/api/passkeys
account.passkeys.remove(id)DELETE /account/api/passkeys/:idRequires sudo
account.tokens.list()GET /account/api/tokensPersonal Access Tokens
account.tokens.create(data?)POST /account/api/tokensRequires sudo; secret returned once
account.tokens.remove(id)DELETE /account/api/tokens/:idRequires sudo
account.orgs.list()GET /account/api/orgsOrgs the signed-in user belongs to
account.orgs.get(id)GET /account/api/orgs/:idOrg detail; requires membership
account.orgs.invitations()GET /account/api/orgs/invitationsPending invitations

All account.* methods use session authentication (the accountGuard cookie). Mutating operations (PATCH, POST, DELETE) send X-CSRF-TOKEN automatically.

AuthkitClientError

Every non-2xx response throws AuthkitClientError:

import { AuthkitClientError } from '@adonis-agora/authkit-react'

try {
  await client.admin.users.get('u_missing')
} catch (err) {
  if (err instanceof AuthkitClientError) {
    console.log(err.status)   // 404
    console.log(err.code)     // 'not_found' (from the error envelope)
    console.log(err.message)  // human-readable message
    console.log(err.body)     // raw JSON response
    console.log(err.isUnauthorized) // true when status === 401
  }
}

Detect session expiry with isUnauthorized and redirect to login:

catch (err) {
  if (err instanceof AuthkitClientError && err.isUnauthorized) {
    window.location.href = '/auth/login'
  }
}

Query hooks

All query hooks return UseQueryOptions — pass them directly to useQuery:

const { data } = useQuery(useUsersQueryOptions({ search: 'alice', page: 1 }))
// data.data — AdminUser[]
// data.meta — { page, size, total }

Paginated listings (users, audit) answer the { meta, data } envelope Lucid's own .paginate() uses, which is the shape every @adonis-agora/* package returns. The request side is { page, size }, matching @adonis-agora/filter.

Admin query hooks

HookQuery keyNotes
useOverviewQueryOptions()authkitKeys.admin.overview()Dashboard metrics
useUsersQueryOptions(params?)authkitKeys.admin.users(params){ search?, page?, size? }{ meta, data }
useUserQueryOptions(id)authkitKeys.admin.user(id)Disabled when id is empty
useUserSessionsQueryOptions(id)authkitKeys.admin.userSessions(id)
useSessionsQueryOptions(accountId?)authkitKeys.admin.sessions(accountId)
useClientsQueryOptions()authkitKeys.admin.clients()
useClientQueryOptions(id)authkitKeys.admin.client(id)
useRolesQueryOptions()authkitKeys.admin.roles()
useOrgsQueryOptions()authkitKeys.admin.orgs()
useOrgQueryOptions(id)authkitKeys.admin.org(id)
useAuditQueryOptions(params?)authkitKeys.admin.audit(params){ type?, page?, size?, subject? }{ meta, data }
useSettingsQueryOptions(orgId?)authkitKeys.admin.settings(orgId)orgId scopes to one org; omitted/null = global
useImpersonationQueryOptions(userId)authkitKeys.admin.impersonation(userId)
useKeysQueryOptions()authkitKeys.admin.keys()Signing-key status

Account query hooks

HookQuery keyNotes
useMeQueryOptions()authkitKeys.account.me()Profile + capability flags
useSecurityQueryOptions()authkitKeys.account.security()Security overview
useAccountSessionsQueryOptions()authkitKeys.account.sessions()
useAppsQueryOptions()authkitKeys.account.apps()Authorized apps
useMfaQueryOptions()authkitKeys.account.mfa()MFA status
usePasskeysQueryOptions()authkitKeys.account.passkeys()
useTokensQueryOptions()authkitKeys.account.tokens()PATs
useAccountOrgsQueryOptions()authkitKeys.account.orgs()
useAccountOrgQueryOptions(id)authkitKeys.account.org(id)
useAccountOrgInvitationsQueryOptions()authkitKeys.account.orgInvitations()

Mutation hooks

Mutation hooks also return options objects — pass them to useMutation:

const m = useMutation(useCreateUserMutationOptions())

Put onSuccess/onError logic and query invalidation in the calling component's handler, not inside the hook options. This keeps hooks as pure configuration and gives each component full control.

Admin mutation hooks

HookArgumentNotes
useCreateUserMutationOptions()CreateUserInput
useUpdateUserMutationOptions(id)UpdateUserInputid bound at hook call
useDisableUserMutationOptions(id)void
useEnableUserMutationOptions(id)void
useResetPasswordMutationOptions(id)voidSends email
useDeleteUserMutationOptions(id)voidCascade delete
useRevokeUserSessionsMutationOptions(id)void
useRevokeAllSessionsMutationOptions(accountId?)void
useCreateClientMutationOptions()CreateClientInput?
useUpdateClientMutationOptions(id)UpdateClientInput?
useDeleteClientMutationOptions(id)void
useRegenerateClientSecretMutationOptions(id)voidSecret returned once
useCreateRoleMutationOptions()CreateRoleInput
useUpdateRoleMutationOptions(name)UpdateRoleInput
useDeleteRoleMutationOptions(name)void
useCreateOrgMutationOptions()CreateOrgInput
useUpdateOrgMutationOptions(id)UpdateOrgInput
useDeleteOrgMutationOptions(id)void
useAddOrgMemberMutationOptions(orgId){ accountId: string; role: string }Adds an existing account
useRemoveOrgMemberMutationOptions(orgId, accountId)voidBoth ids bound at hook call
useUpdateOrgMemberRoleMutationOptions(orgId, accountId)string (role)
useCreateOrgInvitationMutationOptions(orgId){ email: string; role: string }Returns the created invitation
useRevokeOrgInvitationMutationOptions(orgId, invitationId)void
useSetSettingMutationOptions(orgId?){ key: string; value: unknown }orgId writes in the org scope
useRemoveSettingMutationOptions(orgId?)string (key)Resets to lib default
useRotateKeysMutationOptions()KeysRotateInput?{ retire?, keep? }

Account mutation hooks

HookArgumentNotes
useUpdateProfileMutationOptions()UpdateProfileInput
useChangePasswordMutationOptions()ChangePasswordInputRequires sudo + current password
useEmailChangeMutationOptions()RequestEmailChangeInputRequires sudo
useCancelEmailChangeMutationOptions()void
useRevokeSessionMutationOptions()string (session id)
useRevokeOtherSessionsMutationOptions()void
useAccountRevokeAllSessionsMutationOptions()voidSign out of all devices, including the current session
useRevokeAppMutationOptions()string (clientId)
useRemovePasskeyMutationOptions()string (passkey id)Requires sudo
useCreateTokenMutationOptions()CreateTokenInput?Requires sudo; secret returned once
useRevokeTokenMutationOptions()string (token id)Requires sudo

Full example — users page

The pattern: call the hook to get options, pass to useQuery/useMutation, put all success/error/invalidation logic in the handler.

app/admin/ui/pages/users_page.tsx
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
  authkitKeys,
  useUsersQueryOptions,
  useCreateUserMutationOptions,
  useDisableUserMutationOptions,
  useDeleteUserMutationOptions,
  AuthkitClientError,
} from '@adonis-agora/authkit-react'

export default function UsersPage() {
  const queryClient = useQueryClient()
  const [search, setSearch] = useState('')
  const [disablingId, setDisablingId] = useState<string | null>(null)

  // ── Queries ──────────────────────────────────────────────────────────────
  const { data, isLoading } = useQuery(useUsersQueryOptions({ search }))

  // ── Mutations ─────────────────────────────────────────────────────────────
  const createMutation = useMutation(useCreateUserMutationOptions())
  const disableMutation = useMutation(useDisableUserMutationOptions(disablingId ?? ''))
  const deleteMutation = useMutation(useDeleteUserMutationOptions(''))

  // ── Handlers ──────────────────────────────────────────────────────────────
  async function handleCreate(formData: { email: string; name?: string }) {
    try {
      await createMutation.mutateAsync(formData)
      queryClient.invalidateQueries({ queryKey: authkitKeys.admin.users() })
    } catch (err) {
      if (err instanceof AuthkitClientError) {
        alert(err.message)
      }
    }
  }

  async function handleDisable(id: string) {
    setDisablingId(id)
    try {
      await disableMutation.mutateAsync()
      // Invalidate both the list and the single-user query
      queryClient.invalidateQueries({ queryKey: authkitKeys.admin.users() })
      queryClient.invalidateQueries({ queryKey: authkitKeys.admin.user(id) })
    } finally {
      setDisablingId(null)
    }
  }

  // ── Render ────────────────────────────────────────────────────────────────
  if (isLoading) return <p>Loading…</p>

  return (
    <ul>
      {data?.users.map((u) => (
        <li key={u.id}>
          {u.email}
          <button onClick={() => handleDisable(u.id)}>Disable</button>
        </li>
      ))}
    </ul>
  )
}

Organization membership

Membership is where the hook-per-argument style pays off: the ids are bound when you call the hook, so the mutation function itself takes only what changes. That also means a hook bound to a specific member belongs in a component that renders that member — bind it in a row component, not once at the top of the page with a piece of state you have to remember to set.

app/admin/ui/pages/org_members.tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
  authkitKeys,
  useOrgQueryOptions,
  useAddOrgMemberMutationOptions,
  useCreateOrgInvitationMutationOptions,
  useRemoveOrgMemberMutationOptions,
  useUpdateOrgMemberRoleMutationOptions,
} from '@adonis-agora/authkit-react'

function MemberRow({ orgId, accountId, role }: { orgId: string; accountId: string; role: string }) {
  const queryClient = useQueryClient()
  const removeMember = useMutation(useRemoveOrgMemberMutationOptions(orgId, accountId))
  const updateRole = useMutation(useUpdateOrgMemberRoleMutationOptions(orgId, accountId))

  const refresh = () => queryClient.invalidateQueries({ queryKey: authkitKeys.admin.org(orgId) })

  return (
    <tr>
      <td>{accountId}</td>
      <td>
        <select
          value={role}
          onChange={async (e) => {
            await updateRole.mutateAsync(e.target.value)
            refresh()
          }}
        >
          <option value="member">member</option>
          <option value="admin">admin</option>
        </select>
      </td>
      <td>
        <button
          onClick={async () => {
            await removeMember.mutateAsync()
            refresh()
          }}
        >
          Remove
        </button>
      </td>
    </tr>
  )
}

function OrgMembers({ orgId }: { orgId: string }) {
  const queryClient = useQueryClient()
  const { data: org } = useQuery(useOrgQueryOptions(orgId)) // org.members, org.pendingInvitations
  const addMember = useMutation(useAddOrgMemberMutationOptions(orgId))
  const invite = useMutation(useCreateOrgInvitationMutationOptions(orgId))

  async function handleAdd(accountId: string) {
    await addMember.mutateAsync({ accountId, role: 'member' })
    queryClient.invalidateQueries({ queryKey: authkitKeys.admin.org(orgId) })
  }

  async function handleInvite(email: string) {
    const { invitation } = await invite.mutateAsync({ email, role: 'member' })
    queryClient.invalidateQueries({ queryKey: authkitKeys.admin.org(orgId) })
    return invitation
  }

  return (
    <table>
      <tbody>
        {org?.members.map((m) => (
          <MemberRow key={m.accountId} orgId={orgId} accountId={m.accountId} role={m.role} />
        ))}
      </tbody>
    </table>
  )
}

Add a member when the person already has an account; create an invitation when they do not — createInvitation takes an email, addMember takes an accountId. Revoking an invitation (useRevokeOrgInvitationMutationOptions(orgId, invitationId)) is the mirror of creating one. All five mutations touch the org detail, so authkitKeys.admin.org(orgId) is the key to invalidate; add authkitKeys.admin.orgs() when the change alters the list (member counts, for example).

Signing keys

client.admin.keys exposes the JWKS signing key: status() returns the current key age, the rotation policy ({ enabled, maxAgeDays, keep }), how many days until the next rotation, and the list of managed keys with their kid, algorithm, age, and which one is active. rotate(input?) mints a new key, optionally retiring the old ones (retire) and capping how many are kept (keep).

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
  authkitKeys,
  useKeysQueryOptions,
  useRotateKeysMutationOptions,
} from '@adonis-agora/authkit-react'

function KeysPanel() {
  const queryClient = useQueryClient()
  const { data } = useQuery(useKeysQueryOptions())
  const rotate = useMutation(useRotateKeysMutationOptions())

  async function handleRotate() {
    const result = await rotate.mutateAsync({ retire: false })
    console.log(result.newKid, result.retiredKids, result.keptKids)
    queryClient.invalidateQueries({ queryKey: authkitKeys.admin.keys() })
  }

  if (!data) return null

  return (
    <div>
      <p>Current key is {data.ageDays} days old.</p>
      <p>
        {data.policy.enabled
          ? `Rotates automatically every ${data.policy.maxAgeDays} days.`
          : 'Automatic rotation is off.'}
      </p>
      <button onClick={handleRotate} disabled={rotate.isPending}>
        Rotate now
      </button>
    </div>
  )
}

Rotating with retire: false keeps the previous keys published in the JWKS so tokens signed with them still validate — retire them once those tokens have expired. The KeyRotation component is this panel, already built; see Signing key rotation for what happens server-side.

Query key reference

authkitKeys provides stable, hierarchical cache keys. Use them when calling queryClient.invalidateQueries:

import { authkitKeys } from '@adonis-agora/authkit-react'

// Invalidate all admin data
queryClient.invalidateQueries({ queryKey: authkitKeys.admin.all })

// Invalidate the users list (all search params)
queryClient.invalidateQueries({ queryKey: authkitKeys.admin.users() })

// Invalidate a specific user + their sessions
queryClient.invalidateQueries({ queryKey: authkitKeys.admin.user(id) })
queryClient.invalidateQueries({ queryKey: authkitKeys.admin.userSessions(id) })

// Account data
queryClient.invalidateQueries({ queryKey: authkitKeys.account.me() })
queryClient.invalidateQueries({ queryKey: authkitKeys.account.sessions() })

All key shapes:

authkitKeys.admin.all             // ['authkit', 'admin']
authkitKeys.admin.overview()      // ['authkit', 'admin', 'overview']
authkitKeys.admin.users(params?)  // ['authkit', 'admin', 'users', params]
authkitKeys.admin.user(id)        // ['authkit', 'admin', 'users', id]
authkitKeys.admin.userSessions(id)
authkitKeys.admin.sessions(accountId?)
authkitKeys.admin.clients()
authkitKeys.admin.client(id)
authkitKeys.admin.roles()
authkitKeys.admin.orgs()
authkitKeys.admin.org(id)
authkitKeys.admin.audit(params?)
authkitKeys.admin.settings(orgId?)  // ['authkit','admin','settings'] or [...,'settings', orgId]
authkitKeys.admin.impersonation(userId)
authkitKeys.admin.keys()            // ['authkit', 'admin', 'keys']

authkitKeys.account.all
authkitKeys.account.me()
authkitKeys.account.security()
authkitKeys.account.sessions()
authkitKeys.account.apps()
authkitKeys.account.mfa()
authkitKeys.account.passkeys()
authkitKeys.account.tokens()
authkitKeys.account.orgs()
authkitKeys.account.org(id)
authkitKeys.account.orgInvitations()

SSR and testing

In SSR environments window is not available. Pass options explicitly:

// Next.js server component or test
const client = createAuthkitClient({
  baseUrl: process.env.AUTHKIT_ADMIN_API_URL,
  accountBaseUrl: process.env.AUTHKIT_ACCOUNT_API_URL,
  csrfToken: 'test-csrf',
})

In tests, inject a mock fetch:

const client = createAuthkitClient({
  baseUrl: 'http://localhost:3333/admin/api',
  csrfToken: 'test',
  fetch: vi.fn().mockResolvedValue(
    new Response(JSON.stringify({ users: [], total: 0 }), { status: 200 })
  ),
})

Account self-service API

The account API (/account/api/*) is a session-authenticated JSON API served by @adonis-agora/authkit-server. It is not the admin API — it only exposes data and actions for the currently signed-in user.

Authentication uses the account console session cookie (set at login). All mutating endpoints require the X-CSRF-TOKEN header (the client sends it automatically). Sensitive actions (change password, create/revoke PAT, remove passkey) additionally require an active sudo session (authkit_sudo_at) — the server returns 403 sudo_required if the sudo grace period has expired.

The account API is what client.account.* and all use* account hooks consume internally. Routes registered by registerAuthHost (always on when the account console is mounted):

MethodPathDescription
GET/account/api/meProfile, capability flags, sudo state
GET/account/api/securitySessions, MFA, passkeys, pending email change
PATCH/account/api/profileUpdate name / avatar
POST/account/api/passwordChange password (sudo + current password required)
POST/account/api/email-changeRequest email change (sudo required)
POST/account/api/email-change/cancelCancel pending email change
GET/account/api/sessionsList active OIDC sessions
DELETE/account/api/sessions/:idRevoke one session
POST/account/api/sessions/revoke-othersRevoke all other sessions
POST/account/api/sessions/revoke-allSign out of all devices (including current)
GET/account/api/appsList authorized OAuth grants
DELETE/account/api/apps/:clientIdRevoke an app's access
GET/account/api/mfaMFA status (TOTP + passkeys + recovery)
GET/account/api/passkeysList passkeys
DELETE/account/api/passkeys/:idRemove a passkey (sudo required)
GET/account/api/tokensList Personal Access Tokens
POST/account/api/tokensCreate a PAT (sudo required; secret returned once)
DELETE/account/api/tokens/:idRevoke a PAT (sudo required)
GET/account/api/orgsList orgs the user belongs to
GET/account/api/orgs/invitationsList pending org invitations
GET/account/api/orgs/:idOrg detail (requires membership)

All endpoints are capability-probed — if the underlying store doesn't support a feature (e.g. passkeys, orgs, PATs), the route returns { supported: false, ... } rather than erroring. This means the client can safely call any endpoint regardless of the specific store configuration; check the supported field in the response before rendering controls.

Admin Console (dogfood)

The built-in React SPA admin console uses AuthkitClientProvider and all the admin hooks internally — it is the canonical reference implementation. If you are building a custom admin interface or embedding management panels in your app, use the same hooks the console does. See Admin Console for the full route table and how to mount it.

On this page