React (Frontend)
useAuth, gating components, permission checks, headless hooks, and the passkey tiers of @adonis-agora/authkit-react.
@adonis-agora/authkit-react is the frontend SDK for AuthKit. It exports three orthogonal
layers:
| Layer | What it does |
|---|---|
| Auth state | useAuth(), gating components (Authenticated, Guest, CanPermission), pure role helpers |
| Headless hooks | useProfile(), useSessions(), useOrganizations(), the passkey hooks … — plain useState/useEffect, no React Query |
| Typed client + TanStack Query hooks | createAuthkitClient(), useUsersQueryOptions(), useMutation(useCreateUserMutationOptions()) … — for management screens |
The first two layers are covered here. The typed client and TanStack hooks have their own page: Typed Client & TanStack Query.
The package does not authenticate. It consumes the auth state the AdonisJS host already
resolved (via @adonis-agora/authkit-client) and shared as an Inertia
shared-prop, and it talks to JSON endpoints the host exposes.
Install
pnpm add @adonis-agora/authkit-reactreact, react-dom, and @inertiajs/react are peer dependencies (the app provides them).
@simplewebauthn/browser is an optional peer dependency, needed only by the passkey
flows.
1. Share the authkit prop (host)
The React side reads a single shared-prop named authkit. You do not have to assemble it:
the request Authenticator from @adonis-agora/authkit-client builds exactly the object
the frontend expects.
import { defineConfig } from "@adonisjs/inertia";
export default defineConfig({
sharedData: {
// `ctx.auth` is the Authenticator installed by authkit_middleware
authkit: (ctx) => ctx.auth.toSharedProps(),
},
});toSharedProps() resolves the session and returns { user, globalRoles } — where user is
whatever your resolveUser produced and globalRoles are the roles carried in the validated
OIDC claims. It returns null when there is no session, because for an anonymous request
there is nothing to share.
useAuth() treats a missing or null prop as "not authenticated" and never throws, so the
one-liner above is safe as written. In development it does log a warning when the prop is
absent — if you would rather be explicit about the anonymous case (and keep the console
quiet), collapse null into an empty state yourself:
sharedData: {
authkit: async (ctx) =>
(await ctx.auth.toSharedProps()) ?? { user: null, globalRoles: [] },
},The shared user must be serialisable and match AuthUser:
interface AuthUser {
id: string;
email: string;
name?: string;
avatarUrl?: string;
globalRoles: string[];
// escape hatch: the host may attach extra domain fields
[key: string]: unknown;
}Those five fields are the type boundary between backend and frontend — they mirror what
resolveUser / identityToUser derive from the OIDC identity. The index signature is
deliberate: attach your own domain fields (plan, tenant, locale…) and read them in your
components; AuthKit ignores what it does not know.
globalRoles are the roles the IdP asserts about the person — they answer "who is
this?", not "may they do this in this app". Per-application authorization lives in
@adonis-agora/authz (see Roles and authorization);
in React, you ask it through useCan/CanPermission.
2. useAuth()
import { useAuth } from "@adonis-agora/authkit-react";
function Header() {
const { user, isAuthenticated, hasGlobalRole } = useAuth();
if (!isAuthenticated) return <a href="/login">Sign in</a>;
return (
<div>
Hi, {user!.name ?? user!.email}
{hasGlobalRole("ADMIN") && <a href="/admin">Admin</a>}
</div>
);
}useAuth() returns AuthState: { user, isAuthenticated, globalRoles, hasGlobalRole, hasAnyGlobalRole, hasAllGlobalRoles }. The three predicates are bound to the current user,
so components never have to pass user around. The value is memoised on the shared-prop
object, so it is stable across re-renders.
3. Gating components
Authenticated and Guest are the two session-level gates. Both take children and an
optional fallback (default null), and both read useAuth() — nothing else to wire.
import { Authenticated, Guest } from '@adonis-agora/authkit-react'
<Authenticated fallback={<LoginButton />}>
<Dashboard />
</Authenticated>
<Guest>
<MarketingBanner />
</Guest>For a role check, there is no dedicated component: a role is a synchronous boolean on state you already have, so a plain conditional is both shorter and honest about the cost.
function AdminLink() {
const { hasGlobalRole, hasAllGlobalRoles } = useAuth()
return (
<>
{hasGlobalRole('ADMIN') && <a href="/admin">Admin</a>}
{hasAllGlobalRoles(['ADMIN', 'BILLING']) && <a href="/billing">Billing</a>}
</>
)
}Permission gating with useCan and CanPermission
Roles in the shared-prop cannot answer per-resource questions ("may this user edit post
42?"). Those decisions live in the database, behind the authz service, and require a
round-trip. useCan and CanPermission make that round-trip for you.
The contract is fixed: POST <canPath> with JSON { permission, resource? } responding
{ allowed: boolean }. The request is sent with credentials (cookies) and the configured
CSRF token. The path defaults to /authz/can and is configurable on AuthkitProvider
(canPath, or the more explicit endpoints.can).
import { CanPermission, useCan } from "@adonis-agora/authkit-react";
// Component form — declarative, with both fallbacks.
function PostActions({ post }: { post: Post }) {
return (
<CanPermission
permission="posts.update"
resource={post.id}
loadingFallback={<Spinner />}
fallback={<ReadOnlyBadge />}
>
<EditPostButton post={post} />
</CanPermission>
);
}
// Hook form — when the answer drives more than one branch of your own markup.
function EditButton({ post }: { post: Post }) {
const { allowed, loading, error } = useCan("posts.update", post.id);
if (loading) return <Spinner />;
if (error) return <p role="alert">Could not check permissions.</p>;
return allowed ? <button>Edit</button> : null;
}The decision is fail-closed: a network or server failure leaves allowed as false and
surfaces the failure on error. A legitimate denial (allowed: false) leaves error
undefined, so you can distinguish "not allowed" from "could not ask".
Answers are memoised in-process and concurrent checks for the same question are deduped, so
a table with fifty CanPermission rows for the same (permission, resource) pair issues one
request. The cache key folds in the current principal (user.id, or an anonymous
sentinel when signed out): after a logout, a user switch, or an org switch that changes the
user identity, the key changes and the old answer is simply never served again — no manual
clearing in your sign-out path.
checkCan and invalidateCanCache
checkCan is the React-free function behind the hook — useful in an event handler, a router
guard, or a test with a mocked fetch:
import { checkCan, useAuthkitConfig } from "@adonis-agora/authkit-react";
const config = useAuthkitConfig();
async function confirmThenDelete(postId: string) {
const allowed = await checkCan(
config.endpoints.can, // path
"posts.delete", // permission
postId, // resource (optional)
config.csrfToken, // CSRF token (optional)
user.id, // principal — discriminates the cache (optional)
);
if (!allowed) return;
// …
}Pass the same principal you would expect the hook to use (useAuth().user?.id); omitting
it stores the answer under the anonymous principal, which is correct only for anonymous
checks.
invalidateCanCache() drops every memoised answer. Reach for it when permissions change
without the principal changing — an admin edits the signed-in user's roles, a resource
changes owner, or a test needs a clean slate:
import { invalidateCanCache } from "@adonis-agora/authkit-react";
await saveRoleAssignment(); // the same user now has different permissions
invalidateCanCache(); // next render re-asks the serverThis is plain HTTP against a known contract, so it is not coupled to any particular
implementation — but @adonis-agora/authz is the intended peer, and its global-role bridge
can read the globalRoles that @adonis-agora/authkit-client writes into the Agora context.
4. AuthProvider (optional)
Outside Inertia (tests, Storybook), inject the value manually. The context takes precedence over the page props when present:
import { AuthProvider } from "@adonis-agora/authkit-react";
<AuthProvider value={{ user, globalRoles: user.globalRoles }}>
<App />
</AuthProvider>;AuthContext itself is exported too, for the rare case where you need to read or override
the raw context value.
5. AuthkitProvider (config)
Wrap your app once to configure the URLs and JSON endpoints the headless hooks and the pre-built components use. Every field is optional — the defaults point at the host-kit's real routes. In a client-app topology (the app is not the IdP), point these at local app routes that redirect or proxy to the IdP.
import { AuthkitProvider } from "@adonis-agora/authkit-react";
<AuthkitProvider
config={{
loginUrl: "/auth/login", // default
logoutUrl: "/account/logout", // default
profileUrl: "/account/security", // default
csrfToken: page.props.csrfToken, // sent as X-CSRF-TOKEN on JSON mutations
idp: "authkit", // default; 'external' for a third-party IdP
endpoints: {
profile: "/account/security/profile", // GET user / POST update
sessions: "/account/security", // GET sessions / trusted devices
apps: "/account/apps", // GET apps; revoke at `${apps}/:clientId/revoke`
passkeys: "/account/mfa/passkeys", // GET passkeys
orgs: "/account/orgs/json", // GET the user's organizations
orgInvitations: "/account/orgs/invitations/json", // GET pending invitations
can: "/authz/can", // POST permission check
},
}}
>
<App />
</AuthkitProvider>;Overrides are merged one level deep: passing a single endpoints.orgs keeps the defaults for
the other six. canPath is a shorthand for endpoints.can; if you pass both, endpoints.can
wins.
AuthkitProvider also accepts an optional value prop that injects the auth state directly
(same shape as AuthProvider), for apps that do not use Inertia shared props.
idp — which IdP is behind the app
idp (AuthkitIdpMode) tells the components how much of the AuthKit REST surface actually
exists:
| Value | Meaning |
|---|---|
'authkit' (default) | Your backend runs @adonis-agora/authkit-server. Everything works: profile, organizations, authorized apps, passkeys. |
'external' | Your backend authenticates against a third-party IdP (Keycloak, Auth0, Okta…). Components that need the AuthKit REST surface render null instead of calling routes that 404. |
In 'external' mode, UserProfile, OrganizationSwitcher, OrganizationProfile, and
AuthorizedApps degrade to null, while useAuth, Avatar, SignInButton, and
SignOutButton keep working — they only need the shared-prop and the login/logout URLs. See
Bring your own IdP for the full topology.
Reading and building config
| Export | What it is |
|---|---|
useAuthkitConfig() | Reads the resolved config (ResolvedAuthkitConfig) from the nearest provider, or the defaults. |
AuthkitConfigContext | The context itself — for a custom provider or a test wrapper. |
resolveConfig(cfg?) | Pure merge of a partial AuthkitConfig with the defaults. Same function the provider uses. |
DEFAULT_CONFIG | The fully resolved defaults, as a value you can inspect or spread. |
buildAuthUrl(base, returnTo?) | Appends an encoded returnTo query param, choosing ? or &. SSR-safe (never touches window). |
import { useAuthkitConfig, buildAuthUrl } from "@adonis-agora/authkit-react";
function CustomLoginLink() {
const { loginUrl } = useAuthkitConfig();
return <a href={buildAuthUrl(loginUrl, "/dashboard")}>Sign in</a>;
}6. Headless hooks
Composable hooks with no UI and no @tanstack/react-query dependency (plain useState/
useEffect). Data hooks share one shape: { data, loading, error, actions }.
import {
useSignIn,
useSignOut,
useUser,
useProfile,
useSessions,
useAuthorizedApps,
useOrganizations,
useOrganization,
useSwitchOrganization,
useOrgInvitations,
usePasswordStrength,
} from "@adonis-agora/authkit-react";
// OIDC is redirect-based — these navigate the browser.
const { signIn } = useSignIn();
signIn(); // returnTo defaults to the current URL
signIn({ returnTo: "/dashboard" });
const { signOut } = useSignOut();
signOut({ returnTo: "/" });
const { user, isAuthenticated } = useUser(); // alias over useAuth
const profile = useProfile();
await profile.actions.update({ name: "New name" });
const sessions = useSessions();
await sessions.actions.revoke(sessionId);
await sessions.actions.refetch();
const apps = useAuthorizedApps();
await apps.actions.revoke(clientId);
// Organizations (multi-tenancy) — see Organizations for the full API
const { data: orgs, activeOrgId, supported } = useOrganizations();
const org = useOrganization(activeOrgId); // pass null to skip fetching
const sw = useSwitchOrganization();
await sw.activate(orgId);
await sw.deactivate();
const invitations = useOrgInvitations();
await invitations.actions.accept(token);The data hooks fetch JSON against the endpoints configured in AuthkitProvider, with
credentials: 'same-origin' and the optional X-CSRF-TOKEN header. They only fetch inside an
effect, so they are SSR-safe.
Password strength
usePasswordStrength(password, options?) returns { score, feedback? }, where score is
0–4 (4 = strongest) and feedback is an optional list of actionable tips. The result is
memoised on (password, scorer), so typing does not thrash.
const { score, feedback } = usePasswordStrength(password);The default scorer is heuristicScorer, exported so you can call it outside React (server-side
validation messaging, tests) or wrap it. It scores length (8 / 12 / 16 characters) plus
character-class variety (lower, upper, digit, symbol) and returns tips for what is missing — no
dependencies, no word list. It is a fast visual signal, not a real strength estimator.
Swap in a real one through scorer, a (password: string) => { score, feedback? } function:
import { zxcvbn } from "@zxcvbn-ts/core";
import { usePasswordStrength } from "@adonis-agora/authkit-react";
// Define the scorer at module scope: a new function identity on every render
// would defeat the memoisation.
const scorer = (password: string) => {
const { score, feedback } = zxcvbn(password);
return { score, feedback: feedback.suggestions };
};
function PasswordField({ password }: { password: string }) {
const { score, feedback } = usePasswordStrength(password, { scorer });
// …
}The PasswordStrengthMeter component takes the same
scorer prop and does the hook call for you.
Building your own data hook
Every headless data hook above is useResource plus a few actions built on jsonRequest.
Both are exported, so a screen with an endpoint AuthKit does not know about does not need a
different fetching style. See
Building your own hook with useResource.
7. Pre-built components
The kit also ships small, accessible, themeable components built on the hooks above —
SignInButton, SignOutButton, UserButton, UserProfile, AuthorizedApps, Avatar,
PasswordStrengthMeter, OrganizationSwitcher, OrganizationProfile, InteractionForm,
MagicLinkButton, OAuthButton, PasskeyButton, CanPermission, and KeyRotation — with
--authkit-* CSS-variable theming. They have their own page:
React Components.
8. usePasskeyAutofill
usePasskeyAutofill enables WebAuthn conditional mediation (passkey autofill) on a
custom login screen. When mounted, it starts a discoverable-credential ceremony in the
background. The browser shows passkey suggestions directly inside an <input> element with
autocomplete="username webauthn". As soon as the user selects a passkey, onSuccess is
called with the serialised assertion.
import { usePasskeyAutofill, interactionUrls, submitClassicForm } from "@adonis-agora/authkit-react";
function LoginForm({ uid, csrfToken }: { uid: string; csrfToken: string }) {
const urls = interactionUrls(uid);
usePasskeyAutofill({
optionsUrl: urls.passkeyOptions,
verifyUrl: urls.passkeyVerify,
csrfToken,
onSuccess: (assertion) => {
// The verify endpoint answers with a redirect, so it needs a real
// full-page POST — not a fetch.
submitClassicForm({
action: urls.passkeyVerify,
fields: { response: assertion, _csrf: csrfToken },
});
},
});
return (
<form method="POST" action={urls.login}>
<input type="hidden" name="_csrf" value={csrfToken} />
{/* autocomplete="username webauthn" is the key for conditional mediation */}
<input
type="email"
name="email"
autoComplete="username webauthn"
placeholder="Email"
/>
<input type="password" name="password" placeholder="Password" />
<button type="submit">Sign in</button>
</form>
);
}Options
| Option | Type | Default | Notes |
|---|---|---|---|
optionsUrl | string | — | POST endpoint that returns discoverable assertion options (server must support allowCredentials: []). |
verifyUrl | string | — | POST endpoint that verifies the assertion. |
onSuccess | (assertion: string) => void | — | Called with the serialised assertion after the user selects a passkey. |
csrfToken | string? | — | Sent as x-csrf-token header on the options fetch. |
enabled | boolean | true | Set to false to disable the hook (e.g. when auth_methods.passkeyAutofill is off). |
The hook returns nothing: it is an effect, and onSuccess is the only output.
Fail-safes
- SSR-safe — does not run outside a browser environment.
- No WebAuthn support — exits silently; the standard login form is unaffected.
- No conditional mediation support —
PublicKeyCredential.isConditionalMediationAvailable()is checked before starting; exits silently on older browsers. - Unmount — the in-flight ceremony is aborted via
AbortControllerautomatically. - Any error — swallowed; the standard password form is always available.
@simplewebauthn/browserabsent — it is an optional peer dependency: install it in your app (npm i @simplewebauthn/browser@^13) to enable passkeys. There is no CDN fallback — a public CDN in the authentication path is a third party that can take over your login. Autofill is silently skipped when the package is missing (it is progressive enhancement); the explicit flows below throw with install instructions instead.
The server-side counterpart is the passkeyAutofill field in the auth_methods runtime
setting (default true when passkeys are configured). See
Settings for details.
9. Passkeys in three tiers
Every passkey ceremony is the same three beats: POST the options, hand them to
@simplewebauthn/browser, then submit the result as a full-page form POST (the AuthKit
endpoints answer with a redirect, and fetch resolves redirects into a Response instead of
navigating the browser). The package exposes that pipeline at three levels, and you pick by
how much of it you want to own:
Component
A ready button for the default login screen. Renders, runs, disables itself, shows an error.
Hook
Your own button and layout inside a React screen; the hook owns the ceremony and the busy/error state.
Function
A screen that owns its own state machine (a wizard, a reducer, a non-React shell).
Tier 1 — the component
PasskeyButton is the does-everything tier: give it the two
interaction URLs and the CSRF token, and it renders the button, runs the ceremony on click,
disables itself while busy, and shows an inline error on failure.
import { PasskeyButton, interactionUrls } from "@adonis-agora/authkit-react";
const urls = interactionUrls(uid);
<PasskeyButton
optionsUrl={urls.passkeyOptions}
verifyUrl={urls.passkeyVerify}
csrfToken={csrfToken}
>
Sign in with a passkey
</PasskeyButton>;Tier 2 — the hooks
Same flow, your markup. Each hook returns a trigger plus state, and none of them render anything.
usePasskeyLogin
Login by explicit click — the hook behind PasskeyButton. It fetches the options, runs startAuthentication, and then
does the full-page POST to verifyUrl for you.
import { usePasskeyLogin, interactionUrls } from "@adonis-agora/authkit-react";
function PasskeyLogin({ uid, csrfToken }: { uid: string; csrfToken: string }) {
const urls = interactionUrls(uid);
const { authenticate, busy, failed } = usePasskeyLogin({
optionsUrl: urls.passkeyOptions,
verifyUrl: urls.passkeyVerify,
csrfToken,
});
return (
<>
<button type="button" onClick={authenticate} disabled={busy} className="my-button">
{busy ? "Waiting for your passkey…" : "Use a passkey"}
</button>
{failed && <p role="alert">That did not work. Try again or use your password.</p>}
</>
);
}busy stays true after a successful ceremony on purpose: the page is navigating away, and
re-enabling the button would invite a second submit into a dying document. failed resets on
the next attempt.
Pass onSuccess to take over the verification step — the hook then hands you the serialised
assertion, submits nothing, and clears busy (this is the same contract as
usePasskeyAutofill, which makes it easy to route both entry points through one handler):
const { authenticate, busy } = usePasskeyLogin({
optionsUrl: urls.passkeyOptions,
verifyUrl: urls.passkeyVerify,
csrfToken,
onSuccess: (assertion) => myOwnVerificationFlow(assertion),
});usePasskeyAssertion
Confirming identity again (sudo). Sensitive account actions ask the user to prove who they are again. The endpoint answers with
a redirect back to where they were, so the flow is a classic-form submit carrying response,
_csrf, and an optional return_to.
import { usePasskeyAssertion } from "@adonis-agora/authkit-react";
function ConfirmWithPasskey({ csrfToken, returnTo }: { csrfToken: string; returnTo: string }) {
const { run, running, error } = usePasskeyAssertion({
optionsUrl: "/account/confirm/passkey/options",
actionUrl: "/account/confirm/passkey",
csrfToken,
returnTo,
});
return (
<>
<button type="button" onClick={run} disabled={running}>
{running ? "Confirming…" : "Confirm with a passkey"}
</button>
{error && <p role="alert">Could not confirm. Try again.</p>}
</>
);
}If the options request fails, the hook throws before touching the DOM: nothing navigates,
error is set, and the button becomes clickable again.
usePasskeyRegistration
Enrolling a new passkey — the twin of usePasskeyAssertion, running the registration ceremony instead. Same
{ run, running, error } result, same classic-form submit.
import { usePasskeyRegistration } from "@adonis-agora/authkit-react";
function AddPasskey({ csrfToken }: { csrfToken: string }) {
const { run, running, error } = usePasskeyRegistration({
optionsUrl: "/account/mfa/passkeys/options",
actionUrl: "/account/mfa/passkeys/verify",
csrfToken,
});
return (
<>
<button type="button" onClick={run} disabled={running}>
{running ? "Registering…" : "Add a passkey"}
</button>
{error && <p role="alert">Could not register that passkey.</p>}
</>
);
}Tier 3 — the functions
Nothing here touches React. Reach for this tier when your screen already owns the state — a reducer, a multi-step wizard, a test — or when you need to interleave your own steps between the ceremony and the submit.
| Function | What it does |
|---|---|
authenticatePasskey(options, deps?) | POSTs the options, runs startAuthentication, resolves with the serialised assertion. Does not submit anything. |
registerPasskey(options, deps?) | Same, for registration; resolves with the serialised attestation. |
submitPasskeyVerification(options) | Full-page POST of an assertion to a verifyUrl (response + optional _csrf). |
runPasskeyAssertion(options, deps?) | authenticatePasskey + classic-form submit to actionUrl (response, _csrf, return_to). The body of the sudo hook. |
runPasskeyRegistration(options, deps?) | registerPasskey + the same submit. The body of the registration hook. |
submitClassicForm(options, deps?) | Builds a hidden <form> with your fields and submits it — a real navigation. |
loadStartAuthentication() | Lazily imports startAuthentication from the app's @simplewebauthn/browser. |
loadStartRegistration() | The same for startRegistration. |
A screen that owns its own state machine:
import {
authenticatePasskey,
submitPasskeyVerification,
interactionUrls,
} from "@adonis-agora/authkit-react";
async function signInWithPasskey(uid: string, csrfToken: string, signal: AbortSignal) {
const urls = interactionUrls(uid);
// 1. Ceremony only — you decide what happens between the beats.
const assertion = await authenticatePasskey(
{ optionsUrl: urls.passkeyOptions, csrfToken, signal },
// deps are injection points for tests: { fetch, loadStartAuthentication }
);
await recordAnalytics("passkey.ceremony.completed");
// 2. Full-page POST — the server answers with a redirect.
submitPasskeyVerification({ verifyUrl: urls.passkeyVerify, assertion, csrfToken });
}submitClassicForm is the generic version of that last step, and the reason it exists is
worth stating plainly: sudo, passkey verification, and passkey registration all answer with a
302. Only a real form POST makes the browser follow it as a navigation.
import { submitClassicForm } from "@adonis-agora/authkit-react";
submitClassicForm({
action: "/account/confirm/passkey",
fields: { response: assertion, _csrf: csrfToken, return_to: "/account/security" },
});Field insertion order is preserved, method defaults to POST, and the call is a silent
no-op without a DOM — so an SSR pass never explodes on it.
loadStartAuthentication() and loadStartRegistration() are the lazy loaders for the
optional peer. Both reject with an install instruction when @simplewebauthn/browser is
missing, which is what lets the autofill path stay silent while the explicit paths fail loudly:
import { loadStartAuthentication } from "@adonis-agora/authkit-react";
const startAuthentication = await loadStartAuthentication(); // throws if the peer is absent10. Roles and authorization
The role helpers are pure functions over an AuthUser, exported for use outside components
(route guards, selectors, tests):
import {
hasGlobalRole,
hasAnyGlobalRole,
hasAllGlobalRoles,
} from "@adonis-agora/authkit-react";
hasGlobalRole(user, "ADMIN");
hasAnyGlobalRole(user, ["ADMIN", "TEACHER"]);
hasAllGlobalRoles(user, ["ADMIN", "BILLING"]);All three accept null/undefined and answer false, so there is no null-check dance at the
call site. useAuth() returns the same three predicates already bound to the current user.
These read global roles — the ones the IdP asserts. That is the whole authorization
surface AuthKit carries, and deliberately so: AuthKit authenticates, and per-application
authorization (application roles, permissions, resource ownership) belongs to
@adonis-agora/authz, which owns its own tables and policies. In React, that means:
- "Who is this, broadly?" →
useAuth().hasGlobalRole(...), synchronous, free. - "May they do this, here, to this thing?" →
useCan/CanPermission, one cached round-trip to the authz endpoint.