Skip to content
Torii docs

Credential & flow hooks

The drop-in components cover most apps. When you want to build the auth UI yourself (your own markup, copy, and steps), these headless hooks drive the same flows. They run the network and parsing on the Torii runtime (so security-critical logic stays current), while you own the rendering. You never import flow logic from @torii-js/core or hardcode an API URL.

All of these require a <ToriiProvider> ancestor.

Every hook is one of two shapes (there is no third), and the shape tells you exactly where to read the error, so you never have to guess:

  • Flow hooks (multi-step ceremonies: sign-in, sign-up, reset): a discriminated state union you branch on (status: 'idle' | 'error' | …), named actions that resolve to the next state, and reset(). The error lives in state: state.status === 'error' carries state.error.
  • Data hooks (lists you load and mutate: sessions, emails, identities): a named payload, isLoading, a top-level error, refresh(), and mutating actions that resolve to a MutationResult<T>. The error lives in the result: r.ok === false carries r.error.

The one deliberate crossover is useSignUp().resend: it is a single mutation living inside a flow hook, so it returns a MutationResult rather than moving the machine’s state.

Flow actions run on the Torii runtime, which loads asynchronously. Calling an action before it is ready resolves to { status: 'error' } (RUNTIME_NOT_LOADED_ERROR) instead of running. To avoid that, the simple flow hooks (useSignIn, useSignUp, usePasswordReset, useEmailVerification) expose isLoaded (true once the runtime has loaded); disable your submit until it is true, or wrap the screen in <ToriiLoading> / <ToriiLoaded>. If the runtime fails to load entirely, isLoaded stays false, so pair it with <ToriiFailed> to show a fallback rather than a permanently disabled button.

const { isLoaded, signIn } = useSignIn();
<button disabled={!isLoaded} onClick={() => signIn({ email, password })}>Sign in</button>

The two hooks that fetch their own data first, useOAuthSignup (loading-intent) and useChangePassword (loading-status), report that loading in state, so branch on the status rather than reading isLoaded.

HookDrives
useSignIn()Password sign-in
useSignUp()Email + password sign-up (start → verify code)
useInvitationSignUp()Redeem an organization invitation by setting a password
usePasswordReset()Forgot-password (request code → confirm)
useOAuthSignup()The OAuth sign-up continuation (consent) leg
useEmailVerification()Resend a verification email
useChangePassword()Set/change password for the signed-in user
useOAuthProviders()The enabled social providers + a redirect starter

Flows that mint a session (useSignIn, useSignUp, useOAuthSignup) sign the user in automatically. Pass { autoSignIn: false } to handle it yourself; the success state still carries tokens for useAuth().signIn(tokens).

const { state, isLoaded, signIn, verifyMfa, confirmMfaEnrollment, reset } = useSignIn();
await signIn({ email, password });
// state.status: 'idle' | 'submitting' | 'error' | 'success'
// | 'mfa-required' (account has a factor, call verifyMfa(code))
// | 'mfa-enrollment-required' (env enforces MFA, no factor, see below)

When two-factor authentication is in play, signIn resolves to one of two extra states instead of success:

  • mfa-required ({ challengeToken, error? }): the account has a confirmed factor. Call verifyMfa(code) with a TOTP or recovery code; a wrong code keeps the state with error set for a retry.
  • mfa-enrollment-required ({ challengeToken, otpauthUri, secret, error? }): the environment enforces MFA and the user has no factor yet. Render otpauthUri as a QR (and show secret for manual entry), then call confirmMfaEnrollment(code). On success the state is success with recoveryCodes set (show them once).

Sign-up emails a 6-digit code and does not create a session until the code is verified. Read legalSettings to satisfy environments that require legal consent.

const { state, isLoaded, start, verifyCode, confirmMfaEnrollment, resend, legalSettings } = useSignUp();
await start({ email, password, legalConsentAccepted: true });
// state.status -> 'needs-code'
await verifyCode(code);
// state.status -> 'success' (signed in)
// branches: 'code-invalid' (retry), 'code-expired' (start over)
// | 'mfa-enrollment-required' (env enforces MFA, see below)

In an MFA-enforced environment, verifyCode resolves to mfa-enrollment-required ({ challengeToken, otpauthUri, secret, error? }) instead of success: the user must enroll a factor to finish signing up. Render the QR + secret, then call confirmMfaEnrollment(code); on success the state is success with recoveryCodes (shown once). No session is minted until enrollment completes.

start() also accepts an optional unsafeMetadata field to seed an unsafe-metadata bag on the new user at sign-up (max 512 bytes serialized).

Redeems an organization invitation by setting a password. The email, organization, and role come from the invitation, so the only inputs are the token, the chosen password, and the legal-consent flag. On success it creates a verified user, accepts the invitation, and (unless autoSignIn: false) signs the user in. Pass { autoSignIn: false } to handle the session yourself; the success state still carries tokens for useAuth().signIn(tokens). Gate your form on isLoaded (true once the runtime is ready).

const { state, isLoaded, redeem, reset } = useInvitationSignUp();
// InvitationRedeemParams: { token, password, legalConsentAccepted }
await redeem({ token, password, legalConsentAccepted: true });
// state.status: 'idle' | 'submitting' | 'error' | 'success' (signed in)

On 'error', branch on state.error.code: invitation_invalid, invitation_expired, invitation_account_exists, or weak_password. For OAuth-based invitation redemption, use the <InvitationSignUp> component instead: the OAuth leg is a full-page redirect, not a hook action.

const { state, isLoaded, request, confirm, reset } = usePasswordReset();
await request(email); // -> 'code-sent' (never reveals if the email exists)
await confirm({ code, newPassword }); // -> 'done' (or 'code-invalid' to retry)

After a provider redirects a new user back with a #__torii_signup_continue fragment, this loads the pending intent so you can render your own consent screen.

const { state, complete, reset } = useOAuthSignup();
// state.status: 'loading-intent' -> 'intent-ready' (state.intent has email/provider/terms)
await complete({ legalConsentAccepted: true }); // -> 'success' (signed in)
// | 'mfa-required' ({ challengeToken, kind }) (enforced env, hand off to the MFA step)

In an MFA-enforced environment complete may resolve to mfa-required instead of success: consent was recorded but a second factor is owed. Drive it with challengeToken: kind: 'enroll' starts enrollment, kind: 'challenge' verifies an existing factor. The prebuilt <SignUp> handles this for you automatically.

const { state, isLoaded, resend } = useEmailVerification();
await resend(email); // state.status: 'idle' | 'sending' | 'sent' | 'error'
const { state, submit, reset } = useChangePassword();
// state.status: 'loading-status' -> { status: 'idle', hasPassword }
// hasPassword === false for OAuth-only users (omit currentPassword to set one)
await submit({ currentPassword, newPassword }); // -> 'done'
// reset() returns the machine to 'idle' (keeping the known hasPassword) so the user can retry after an error
const { providers, isLoading, error, start } = useOAuthProviders();
providers.map((p) => <button onClick={() => start(p.name)}>{p.displayName}</button>);
// start() is a full-page redirect to the provider

This is a data-shaped hook (a named providers payload, a top-level error, and a redirect-only start, with no state and no reset), so it does not follow the flow-hook “state + reset” shape described at the top of the page.

These follow the same shape as useSessions(), useEmailAddresses(), useIdentities(), and useUserProfile(): a named payload plus isLoading, error, refresh, and mutating actions that resolve to a MutationResult.

Every mutating action resolves to a discriminated result, so you handle the outcome from a single await without reading the hook’s error afterwards:

type MutationResult<T> = { ok: true; value: T } | { ok: false; error: ToriiError };
const r = await revokeSession(id);
if (r.ok) toast('Signed out that device');
else toast(r.error.message);

Hooks never return localized strings. Errors are a ToriiError carrying a machine-readable code (plus a default message and details). Branch on code and supply your own copy, or reuse the SDK labels from useAuth().labels.