Let users manage 2FA from your app
Give a signed-in user control of their authenticator, passkeys and recovery codes through your backend.
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.
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/totpTo 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
| Method | Path after /api/v1/auth/account/factors | Behavior |
|---|---|---|
| GET | / | One inventory snapshot; no codes or TOTP secret |
| POST | /totp/start | Fresh proof; secret returned once. Allowed while an authenticator is active to replace it |
| POST | /totp/confirm | Bind, or atomically replace the active authenticator; initial recovery codes returned once |
| DELETE | /totp | Fresh strongest proof; required-factor policy applies |
| POST | /recovery-codes | Generate or explicitly rotate; invalidates the preceding set |
| POST | /passkeys/start | Registration options and ceremony ID |
| POST | /passkeys/finish | ceremony_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
| Code | Action |
|---|---|
| fresh_authentication_required | 403; reverify using min_acr, then retry the intended change |
| session_app_mismatch | 403; use a session created by the calling app |
| last_strong_factor_required | 403; add another eligible strong factor first |
| backend_request_required | 403; move the call to your backend |
| app_passkey_domain_unverified | 403; configure and verify the exact app origin |
| flow_superseded | 409; restart a superseded sign-in/reverify flow; invalid sessions use 401 session_invalid |
| factor_proof_rejected | 401; request a new proof; never blindly retry |
| invalid_factor_credentials | 401; supply the app credential and its user session |
| totp_already_enrolled | 409; re-read inventory. Replacement needs no removal: start while active, then confirm |
| passkey_already_registered | 409; select a different authenticator |
| invalid_passkey_name | 400; use 1–100 characters without surrounding whitespace or control characters |
| passkey_not_found | 404; re-read inventory; the passkey was removed or belongs to another user |
| user_passkey_limit | 409; remove a passkey before adding another |
| user_passkey_unavailable | 404; passkeys are disabled by policy or unavailable on this origin |
| user_totp_unavailable | 404; authenticator apps are disabled by policy for this account |
| session_invalid | 401; the session is unknown, expired, revoked or outside this tenant; sign the user in again |
| stronger_proof_unavailable_here | 403; this app cannot collect the proof required; send the user to a surface that can, or use another method |
| recovery_proof_required | 403 during sign-in setup; restart sign-in and choose a recovery code, or manage methods where the passkey works |
| remember_device_unavailable | 400; retry without remember_device and only offer it when trusted_device_available is true |
| strong_factor_required | 403; bind a strong factor before generating codes |
| rate_limited | 429; respect Retry-After; limits are shared across transports |
| user_authentication_method_locked | Use another strong method or audited organisation recovery |
| endpoint_removed | 410; replace the retired integration |
| unsupported_login_flow | 400; 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.