> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# MFA factor lifecycle: TOTP, passkey, push, and backup-code enrollment, verification, and lockout

> How each Verify MFA factor is enrolled, verified, retried, and retired — and how the composite factor chain composes TOTP, passkey, push, and backup codes into one step-up ladder.

# Factor lifecycle

Verify ships four possession-of-secret MFA factors alongside the carrier-delivered OTP: **TOTP** (authenticator app), **passkey** (WebAuthn / FIDO2), **push** (device public-key + signed challenge), and **backup codes** (single-use recovery). None of the four carry a carrier delivery or a wallet deduct — each validates proof-of-possession against a server-side secret or device key pair. This page is the reference for how a factor moves from enrollment through verification to retirement, and how the four compose into one factor chain. For the onboarding walkthrough, see [Verify profiles: fallback chains and advanced factors](/guides/verify-fallback-chains); for the endpoint list, see the [Verify overview](/verify/overview).

<Note>
  **The factor suite is API-only — there is no dashboard screen.** TOTP, backup codes, passkeys, and push are enrolled, verified, and revoked from inside your own application, where the end user is present to scan a QR code, run a WebAuthn ceremony, or approve a push prompt. Drive them straight from the endpoints below or the SDK.
</Note>

## What a factor is

A factor is a server-side record that proves an end user possesses a secret only they should hold. Every factor belongs to one tenant and one user; a factor created by one tenant is never visible to another. The four factors split by *where the secret lives*:

| Factor | Secret location | Phishing-resistant | Channel |
| - | - | - | - |
| TOTP | Shared symmetric seed on device + server | No (code can be phished) | `totp` |
| Passkey | Device-only private key (WebAuthn) | Yes (origin-bound) | `passkey` |
| Push | Device public key + signed nonce | Yes (signed challenge) | `push` |
| Backup code | Single-use plaintext, hashed at rest | No (recovery only) | `backup_code` |

Pick passkey or push for phishing-resistant step-up; pick TOTP for the broadest device compatibility (any RFC 6238 authenticator); pick backup codes as the recovery lane for users who have lost their device and their carrier access.

## Factors vs the carrier delivery chain

The four factors are **independent sibling endpoints** — each has its own enrollment, verification, and revocation path under `/verify/factors/*`, `/verify/push/*`, and `/verify/passkey/*`. There is no platform-side chain that falls back across these factors on a declined verification. The only fallback chain Verify ships is the **carrier delivery chain** (`channels[]` on a profile), which falls back across *delivery* channels (SMS → voice → email) when a send times out — it does not fall back across the possession factors, and a factor verification never falls back to a carrier OTP.

What `/verify/send` does with a factor channel: sending `channel: "totp"`, `"push"`, or `"backup_code"` to `POST /verify/send` does **not** issue an OTP or enter the delivery chain. The send endpoint short-circuits with a typed 422 (for `totp` and `backup_code`) or 400 (for `push`) that points your integration at the matching factor endpoint — those channels are possession-of-secret factors with their own lifecycle, not delivery channels. Composing a step-up flow across factors is your integration's design: your app decides which factor to try, calls its endpoint, and falls back to a different factor (or to backup codes for recovery) in your own code. The platform gives you the four endpoints; it does not chain them for you.

## TOTP — authenticator app

RFC 6238 time-based one-time password factors (Google Authenticator, Authy, 1Password, etc.). The factor is a shared symmetric seed; the server and the device each derive the same 6-digit code from the current 30-second step.

### Enroll

`POST /verify/factors/totp` creates the factor and returns the `otpauth://` provisioning URI for QR rendering plus the base32 secret **once**. Subsequent reads return metadata only — the plaintext secret is never re-exposed after creation. Ten single-use recovery codes are minted on the same create call and surfaced once; only their SHA-256 hashes are persisted.

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/verify/factors/totp` | List TOTP factors |
| `POST` | `/verify/factors/totp` | Create a factor (returns `otpauth://` URI + secret once) |
| `POST` | `/verify/factors/totp/{id}/verify` | Validate a 6-digit code |
| `DELETE` | `/verify/factors/totp/{id}` | Delete a factor |
| `POST` | `/verify/factors/totp/{id}/recovery-codes/regenerate` | Invalidate and re-mint 10 recovery codes |

### Verify

`POST /verify/factors/totp/{id}/verify` validates a 6-digit code against the current step with a ±1 step skew (the prior and next 30-second windows are accepted to absorb clock drift). A match returns the factor's status; a mismatch does **not** lock the factor on a single bad code — there is no in-factor attempt counter and no lockout, so a fresh code in the next step window retries cleanly. Tenant-level request rate limiting (shared with the other Verify write endpoints) still throttles a client that hammers `verify`, but the TOTP factor itself never goes "locked."

### Retire

`DELETE /verify/factors/totp/{id}` removes the factor. `POST /verify/factors/totp/{id}/recovery-codes/regenerate` invalidates every prior recovery code and mints a fresh set of 10 — call it when a recovery code set may have been exposed, not on a schedule.

## Backup codes

Single-use, hashed-at-rest recovery codes for users who have lost their device and carrier access. The plaintext exists exactly long enough to show it once; the server stores SHA-256 hashes only.

### Enroll

`POST /verify/factors/backup-codes` mints 10 plaintext codes returned **once**. Each code can succeed at most once, enforced by an `UPDATE ... WHERE consumed_at IS NULL` gate — the verify call atomically marks the code consumed and rejects a second use of the same code.

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/verify/factors/backup-codes` | List backup-code factors |
| `POST` | `/verify/factors/backup-codes` | Mint 10 single-use codes (returned once) |
| `POST` | `/verify/factors/backup-codes/{id}/verify` | Consume one code |
| `DELETE` | `/verify/factors/backup-codes/{id}` | Delete a factor (idempotent) |

### Verify

`POST /verify/factors/backup-codes/{id}/verify` consumes one code and returns `remaining_count`, so your UI can prompt regeneration before the set runs out. A verify against an exhausted or already-consumed code is rejected — there is no retry window on a backup code, because each code is single-use by construction.

### Retire

`DELETE /verify/factors/backup-codes/{id}` is idempotent. Regenerate by creating a new factor; a backup-code factor is the recovery lane, not a primary factor — re-mint when the `remaining_count` is low.

## Push — device public key

Phishing-resistant MFA via a device public key and a signed challenge (mirrors Twilio Verify `factor.type="push"`). The private key never leaves the device; the server verifies a signature over a server-issued nonce.

### Enroll

`POST /verify/push/factors` registers a device public key and returns the factor id. The factor is inert until a challenge is issued and verified.

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/verify/push/factors` | Register a device public key |
| `POST` | `/verify/push/factors/{factorId}/challenges` | Issue a new challenge |
| `POST` | `/verify/push/challenges/{challengeId}/verify` | Submit a signed nonce |
| `POST` | `/verify/push/factors/{factorId}/revoke` | Revoke a paired device |

### Verify

The two-step flow is **issue** (`POST /verify/push/factors/{factorId}/challenges`) then **submit** (`POST /verify/push/challenges/{challengeId}/verify`). The device signs a server-issued nonce with its private key; the server verifies the signature against the registered public key. Each challenge has `max_attempts = 3` and a default TTL of 120s (capped at 600s) — the attempts counter increments on every verify call *before* the signature is checked, and a third failed attempt moves the challenge to `denied`. An expired challenge moves to `expired`; a stale, already-decided, or revoked factor is rejected. Push dispatch to the device runs through the push dispatcher (FCM / APNs / Web Push) with a polling fallback; a challenge the user never answers expires and a fresh challenge must be issued.

### Retire

`POST /verify/push/factors/{factorId}/revoke` revokes the paired device and invalidates its outstanding challenges.

## Passkey — WebAuthn / FIDO2

Phishing-resistant passkey factors. The registration and authentication ceremonies follow the WebAuthn spec; attestation and assertion verification (CBOR / COSE / signature) runs server-side.

### Enroll

`POST /verify/passkey/registration/options` issues the WebAuthn registration ceremony options; `POST /verify/passkey/registration/verify` verifies the attestation and creates the factor. The credential's public key is stored server-side; the private key stays on the device (or hardware security module).

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/verify/passkey/registration/options` | Issue a registration ceremony |
| `POST` | `/verify/passkey/registration/verify` | Verify attestation, create the factor |
| `POST` | `/verify/passkey/authentication/options` | Issue an authentication ceremony |
| `POST` | `/verify/passkey/authentication/verify` | Verify the assertion, bump the counter |
| `POST` | `/verify/passkey/factors/{factorId}/revoke` | Revoke a paired passkey |

### Verify

The two-step flow is **options** (`POST /verify/passkey/authentication/options`) then **verify** (`POST /verify/passkey/authentication/verify`). Each ceremony has `max_attempts = 3` and a default challenge TTL of 300s (capped at 600s); a denied assertion is terminal and a third failed attempt ends the ceremony. A successful assertion bumps the per-credential counter — a counter that does not advance (a replay) auto-revokes the factor, because a credential's counter must strictly increase across authentications. The origin binding is verified on every assertion, so a passkey registered for `auth.example.com` cannot be used from a different origin.

### Retire

`POST /verify/passkey/factors/{factorId}/revoke` revokes the paired passkey. A revoked passkey's assertions are rejected at the options stage.

## When a given factor is the right choice

| Profile shape | First choice | Recovery lane |
| - | - | - |
| Phishing-resistant step-up, device present | Passkey or push | TOTP, then backup codes |
| Broadest device compatibility, no platform auth | TOTP | Backup codes |
| Recovery from lost device + lost carrier access | — | Backup codes |
| Origin-bound web auth | Passkey | Push, then TOTP |

Backup codes are the recovery lane for every other factor — mint them when the user enrolls a TOTP, passkey, or push factor, and surface `remaining_count` so the user regenerates before the set runs out.

## Retry and lockout per factor

| Factor | Attempt bound | Lockout | Recovery |
| - | - | - | - |
| TOTP | ±1 step skew (no in-factor attempt counter, no lockout) | None — the factor never locks; a fresh step retries | Wait for the next 30s step window |
| Backup code | Single-use per code (`consumed_at` gate) | A consumed code is dead; the set is exhausted when `remaining_count` hits 0 | Mint a new set |
| Push | `max_attempts = 3` per challenge; `attempts` increments on every verify call before the signature is checked | A challenge goes `denied` at 3 failed attempts, and an expired challenge goes `expired` | Issue a fresh challenge |
| Passkey | `max_attempts = 3` per ceremony; the counter increments on a denied assertion | A denied ceremony is terminal; a counter that does not advance (replay) auto-revokes the factor | Re-issue options |

No factor has a global "locked" flag that disables the whole factor type — the lockout is per-code, per-challenge, or per-assertion, and a fresh issuance clears it. The TOTP factor has no per-factor lockout at all: a mismatched code costs nothing but a wait for the next step. Tenant-level request rate limiting applies on top of all four, so a client that hammers any `verify` endpoint is throttled regardless of the per-factor semantics.

## Scope and isolation

Write operations for TOTP and backup-code (create, verify, delete, and recovery-code regeneration) require the `verify:write` scope; dashboard session callers must also hold an owner, admin, or developer role. TOTP and backup-code read endpoints, plus every push and passkey endpoint, are gated by authentication and per-tenant rate limiting only — any key scoped to the tenant can call them, so restrict which keys reach these flows. A factor created by one tenant is never visible to another; the factor record is tenant-isolated at the data layer.

## Related

* [Verify overview](/verify/overview) — channel capabilities, send/check, and the factor endpoint table
* [Verification configuration](/verify/configuration) — profiles, templates, expiry, attempts, and the carrier fallback chain
* [OTP approvals](/verify/otp-approvals) — the two-officer gate for template edits and resend bursts
* [Verify profiles: fallback chains and advanced factors](/guides/verify-fallback-chains) — the onboarding walkthrough that pairs the carrier chain with the factor chain
* [Verify API reference](/api-reference/endpoints/verify) — every factor endpoint


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.