Skip to content
Authreads Docs

Let users manage 2FA from your app

Give a signed-in user control of their authenticator, passkeys and recovery codes through your backend.

Development contract: deploy the factor API before enabling this screen. Call from your backend only. Never expose the app login secret, session bearer or activity companion in browser JavaScript, URLs, analytics or logs.

Authenticate the app and its user

Every factor call requires X-Tenant-Id, the app login credential as Authorization: Bearer alcs_…, the raw session bearer as x-session-id, and its signed x-session-activity companion. The session must belong to the same tenant, user pool and application. An API Access machine token or hosted cookie cannot authorize these calls.

Call from your backend and strip the browser Origin header before forwarding: any request carrying Origin is refused with 403 backend_request_required. Forward the end user's User-Agent if you want accurate device names in security notices; without it the notice omits the browser and platform lines rather than naming your backend.

GET /api/v1/auth/account/factors returns the confirmed authenticator, recovery-code metadata, all pool passkey metadata and the available proof methods in one consistent snapshot. Passkeys belong to the pool; only a key on an exact verified origin of this app can be changed through this app. Metadata never includes credential material.

Reverify, bind the authenticator, save the codes

Use the inventory's methods.required_strength: request 2fa for password/email, mfa for an existing authenticator or phr for an eligible app passkey, prefixed by urn:authreads:acr:. A refusal also supplies min_acr. Reverification records evidence on the existing session; it issues no replacement grant.

Backend sequence (credentials on every call)
GET /api/v1/auth/account/factors
x-session-id: <session bearer>
x-session-activity: <signed companion>

POST /api/v1/auth/login/flow/reverify
{"session_id":"<same session bearer>","min_acr":"urn:authreads:acr:2fa"}
# Follow next_method and replace flow_token on each transition.
POST /api/v1/auth/login/flow/verify-email-code
{"flow_token":"<latest flow token>","code":"<user proof>"}
# Completion: session_reverified=true. Keep the original session.

POST /api/v1/auth/account/factors/totp/start
# Show qr_svg/key privately. Start returns the secret once.
POST /api/v1/auth/account/factors/totp/confirm
{"code":"<six-digit authenticator code>"}
# 200 {"recovery_codes":["<ten single-use codes>"]} on first strong-factor binding.
# Subsequent bindings return recovery_codes:null.
# Copy/download privately; require the user to acknowledge saving them.

# A later change needs a new reverify at the current strongest method.
DELETE /api/v1/auth/account/factors/totp

To replace an authenticator, call /totp/start while one is active; /totp/confirm swaps it atomically, so the account is never left without one. No removal is needed first.

The freshness window, setup proof binding, single-use mutation proof and guessing limits are shared with hosted account pages. Start preserves proof for its bound confirmation. A successful change spends it. Fresh email proof cannot remove a current eligible authenticator or passkey. Required policy refuses removal of the last eligible strong factor. Recovery proof is the existing explicit account-recovery exception.

Initial password-only setup remains available inside the sign-in enrolment flow when policy disables email and requires the first strong factor. It does not upgrade authentication assurance: enrolment and authentication are separate steps.

Factor operations

MethodPath after /api/v1/auth/account/factorsBehavior
GET/One inventory snapshot; no codes or TOTP secret
POST/totp/startFresh proof; secret returned once. Allowed while an authenticator is active to replace it
POST/totp/confirmBind, or atomically replace the active authenticator; initial recovery codes returned once
DELETE/totpFresh strongest proof; required-factor policy applies
POST/recovery-codesGenerate or explicitly rotate; invalidates the preceding set
POST/passkeys/startRegistration options and ceremony ID
POST/passkeys/finishceremony_id, credential, friendly_name; returns id and recovery_codes
PATCH/passkeys/{id}Fresh proof and friendly_name
DELETE/passkeys/{id}Fresh proof; revokes authority proven by this key

Every passkey operation requires Authreads-Passkey-Origin, the exact verified web or registered native origin. Forward registration options to the client, then its signed WebAuthn credential back to your backend. The server rechecks the domain, app, credential generation, user and session under the mutation lock, including at finish.

One-time secrets and network failures

All responses use Cache-Control: no-store. No plaintext recovery codes are persisted, logged or audited, and no secret-response cache replays them. First strong-factor binding returns ten codes in flow-v2, API and hosted responses; subsequent bindings return null. Each recovery code works once. Never save codes in localStorage, sessionStorage or a shared server cache.

A retry never issues another set using spent proof. There is no Idempotency-Key: if a success response is lost, read inventory, reverify freshly, then explicitly POST /recovery-codes and show the replacement once. Do not automatically retry mutations. After an operator resets a user's factors, recovery codes are reissued at the user's next strong-factor enrolment. Hosted UI requires acknowledgement before continuing. Factor calls validate activity but do not renew its idle clock; keep using the normal session activity refresh contract.

Actionable errors

CodeAction
fresh_authentication_required403; reverify using min_acr, then retry the intended change
session_app_mismatch403; use a session created by the calling app
last_strong_factor_required403; add another eligible strong factor first
backend_request_required403; move the call to your backend
app_passkey_domain_unverified403; configure and verify the exact app origin
flow_superseded409; restart a superseded sign-in/reverify flow; invalid sessions use 401 session_invalid
factor_proof_rejected401; request a new proof; never blindly retry
invalid_factor_credentials401; supply the app credential and its user session
totp_already_enrolled409; re-read inventory. Replacement needs no removal: start while active, then confirm
passkey_already_registered409; select a different authenticator
invalid_passkey_name400; use 1–100 characters without surrounding whitespace or control characters
passkey_not_found404; re-read inventory; the passkey was removed or belongs to another user
user_passkey_limit409; remove a passkey before adding another
user_passkey_unavailable404; passkeys are disabled by policy or unavailable on this origin
user_totp_unavailable404; authenticator apps are disabled by policy for this account
session_invalid401; the session is unknown, expired, revoked or outside this tenant; sign the user in again
stronger_proof_unavailable_here403; this app cannot collect the proof required; send the user to a surface that can, or use another method
recovery_proof_required403 during sign-in setup; restart sign-in and choose a recovery code, or manage methods where the passkey works
remember_device_unavailable400; retry without remember_device and only offer it when trusted_device_available is true
strong_factor_required403; bind a strong factor before generating codes
rate_limited429; respect Retry-After; limits are shared across transports
user_authentication_method_lockedUse another strong method or audited organisation recovery
endpoint_removed410; replace the retired integration
unsupported_login_flow400; omit the version header or send 2

Test amr membership, never array equality: session evidence can accumulate and ordering is not an authorization rule. Use acr and auth_time to evaluate assurance and freshness. See sessions and the error catalogue.