> ## 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.

# Troubleshooting: Verify factor suite (TOTP, push, passkey, backup codes)

> Diagnose an enrolled factor whose challenge never verifies — TOTP codes rejected, push prompts that never land, passkey ceremonies that fail attestation/assertion, or a revoked device — and fix the cause at the enrollment, clock, or origin layer.

# Troubleshooting: Verify factor suite (TOTP, push, passkey, backup codes)

The MFA factor suite — TOTP, push, passkeys, and backup codes — proves
possession of a secret or device key instead of delivering an OTP, so its
failures surface inside your own application's factor flow rather than on
a `/verify/send` row. Work the symptom map first, then the per-factor
cause table. (OTP send/check failures are on the [Verify OTP page](/troubleshooting/verify-otp).)

## Symptom map

| Symptom                                                               | Most likely causes                                                                                                                                                            | What to do                                                                                                                        |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Factor enrollment succeeded but the challenge never verifies**      | The clock skimmed out of the TOTP window, the challenge TTL lapsed before the user responded, or the enrollment captured an artifact (secret, public key) your app later lost | Read the error `reason` in the response `details`; match it in the cause table for that factor                                    |
| **Passkey ceremony returns an attestation or assertion error**        | The relying party set (`rpId` + origin) you passed at enrollment does not match the domain the ceremony runs on, or the challenge id is reused/stale                          | Match the error `reason` in the passkey cause table: RP id and origin are pinned at challenge-issue time and re-checked on verify |
| **TOTP code drifted — user types the right digits and gets rejected** | Device clock outside the ±1-step (30 s) skew window, or a code the user already used successfully (replay)                                                                    | Confirm the client clock against NTP before treating enrollment as broken                                                         |
| **Push prompt silent — the device never shows the challenge**         | The factor was revoked, the device key was rotated without re-registration, or the challenge expired in the queue before your app polled it                                   | Issue a fresh `POST /verify/push/factors/{factorId}/challenges` and confirm the factor is still active                            |
| **Backup code fails or the factor is running dry**                    | The code was already consumed (codes are single-use), or `remaining_count` is at zero                                                                                         | Mint a fresh factor via `POST /verify/factors/backup-codes` or regenerate TOTP recovery codes                                     |

## Cause table per factor

### TOTP (authenticator app)

A rejected TOTP returns `INVALID_CODE` (`422`) with either "TOTP code is
incorrect or expired" or "Recovery code is invalid or already used".
Creation is immediate — `POST /verify/factors/totp` returns the
`otpauth://` URI and secret once, and the factor is live from that
response; the first `/verify` call is what reveals a bad pairing.

| Cause                             | Why it fails                                                                                                                       | Remediation                                                                                                                                               |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Clock drift                       | The verifier accepts the current 30 s step plus one step either way; a device skewed past \~30 s produces codes outside the window | Sync the device clock to NTP. If you serve users you cannot tell to fix their clock, prompt re-verification with two consecutive codes at enrollment time |
| Replay                            | A code that already verified once is rejected — the step counter advances on success                                               | Generate a fresh code; do not reuse the one that just succeeded                                                                                           |
| Lost or rotated device            | The user re-enrolled on a new device and the old factor still exists                                                               | Delete the stale factor (`DELETE /verify/factors/totp/{id}`) and create a new one; treat the factor id as disposable, not the identity                    |
| Recovery code exhausted or reused | Recovery codes are single-use and hashed at rest; the original plaintext is unrecoverable                                          | Regenerate with `POST /verify/factors/totp/{id}/recovery-codes/regenerate` — this invalidates the old batch and mints ten new codes                       |

### Push (device key + signed challenge)

Push failures split into issuing-time and verify-time causes:

| Cause                                | Response                                                                                                | Remediation                                                                                                                                                           |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Factor revoked                       | `409 CONFLICT` with `details.reason: "FACTOR_REVOKED"` on challenge issue                               | Revocation is irreversible — register the device again (`POST /verify/push/factors`) and update your app to the new factor id                                         |
| Bad device key at registration       | `422` with `details.reason: "PUBLIC_KEY_INVALID"`, `ALGORITHM_UNSUPPORTED`, or `KEY_FORMAT_UNSUPPORTED` | Regenerate the key pair on the device and resubmit; the matching private key must never leave the device                                                              |
| Challenge expired in the queue       | The challenge TTL (default 120 s, capped at 600 s) passed before the user approved                      | Issue a new challenge. If your UI asks for approval asynchronously, pass `ttlSeconds` up to 600 and disable the approve button once it lapses                         |
| Wrong signing string or nonce        | The device signs `factorId.challengeId.nonce`; any deviation fails verification                         | Sign exactly that concatenation; do not hash or reorder it                                                                                                            |
| Silent device after all of the above | —                                                                                                       | Confirm your push-notification path actually reached the device — Orbit validates the signature, but delivering the prompt to the user's screen is your app's channel |

### Passkeys (WebAuthn / FIDO2)

Attestation and assertion verification run server-side; RP id and origin
are pinned when the challenge is issued and cross-checked against
`clientDataJSON` on verify, which is what makes a phishing-origin relay
fail closed.

| Cause                           | Response                                                                                             | Remediation                                                                                                                                                                                                              |
| ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Origin / RP-id mismatch         | `422` with `details.reason: "ATTESTATION_INVALID"` or `"ASSERTION_INVALID"`                          | Set the relying party (`rpId`, origin) to your own domain at enrollment and never change it for an existing factor — mismatched RP config is rejected, not adapted. If you renamed your domain, re-enroll affected users |
| Stale or reused challenge       | `409 CONFLICT` with `details.reason: "CHALLENGE_TERMINAL"`, or `410 GONE` with `"CHALLENGE_EXPIRED"` | Each ceremony consumes one challenge; mint a fresh options call per attempt and submit within the TTL (default 300 s, range 30–600)                                                                                      |
| Wrong ceremony type             | `409` `CHALLENGE_WRONG_TYPE` (`expected: "registration"` or `"authentication"`)                      | Route registration payloads to `/verify/passkey/registration/verify` and assertion payloads to `/verify/passkey/authentication/verify`                                                                                   |
| Signature / counter check fails | `422` with `ATTESTATION_UNVERIFIED`, or a revoked factor                                             | A counter regression auto-revokes the factor (WebAuthn L3 cloning signal). Tell the user the credential must be re-registered; you cannot override the revoke                                                            |
| Malformed ceremony payload      | `422` `ASSERTION_MALFORMED`                                                                          | Pass the browser's `navigator.credentials.get()` / `.create()` output back verbatim — do not re-encode or trim fields                                                                                                    |

### Backup codes

| Cause                          | Response                                                               | Remediation                                                                           |
| ------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Code already consumed          | `INVALID_CODE` — the single-use gate rejects a previously matched code | Read `remaining_count` on every successful consume and prompt regeneration at zero    |
| Wrong factor id ↔ user pairing | `INVALID_CODE`                                                         | Match the factor id to the user the codes were minted for; codes are not cross-factor |

### SNA / flashcall possession channels

`channel: "sna"` and `channel: "flashcall"` prove possession without an
OTP, and both fail closed with `503 SERVICE_UNAVAILABLE` and
`details.reason: "PROVIDER_NOT_WIRED"` when their dependency is missing:

| Channel     | Missing dependency                              | Remediation                                                               |
| ----------- | ----------------------------------------------- | ------------------------------------------------------------------------- |
| `sna`       | No device-bound `device_token` on the send body | Pass the CAMARA token from the client; the endpoint never substitutes one |
| `flashcall` | No CLI pool provisioned on the tenant           | Have your pool provisioned; until then the channel fails closed by design |

## Data-retention and revoke circuit: reading factor state and re-registering

All factor state is tenant-scoped to your organization, readable through
the list endpoints, and removable through the delete/revoke endpoints —
re-registration is a client-side flow you own, in line with tenancy being
yours to operate:

* **TOTP / backup codes:** list with `GET /verify/factors/totp` or
  `GET /verify/factors/backup-codes`; remove with the matching
  `DELETE` (backup-code delete is idempotent).
* **Push:** revoke with `POST /verify/push/factors/{factorId}/revoke`
  (irreversible — register a fresh factor after revoking).
* **Passkeys:** revoke with `POST /verify/passkey/factors/{factorId}/revoke`
  (idempotent — a second call returns the same response).

Never try to repair a factor in place: delete or revoke it, then run the
enrollment ceremony again with the user present. Enrollment secrets
(`otpauth://` URI, plaintext backup codes) are returned once at creation;
if you lost them, the only clean path is re-enrollment.

## What not to do

* **Do not retry a factor challenge unchanged.** `CHALLENGE_TERMINAL`,
  `CHALLENGE_EXPIRED`, and `FACTOR_REVOKED` are state, not rate limits —
  issue a fresh challenge or re-enroll.
* **Do not loosen the RP-id / origin to make a failing ceremony pass.**
  The origin cross-check is the phishing resistance; a loosened check is
  a security regression, not a fix.
* **Do not disable the passkey counter regression revoke.** That signal
  means the credential was cloned; re-register the credential and tell
  the user.
* **Do not treat the 30-minute `/check` lockout or recipient rate limits
  as factor problems.** Those belong to OTP sends — see
  [Verify OTP](/troubleshooting/verify-otp).

## Escalation packet

If a factor still fails after you have worked the cause table for its
type, email [support@devotel.io](mailto:support@devotel.io) with:

1. Your **tenant id** (returned as `organizationId` by `GET /api/v1/me`,
   and shown in the dashboard under Settings → Organization).
2. The **factor id** (`vftotp_…` for TOTP, `vfbc_…` for backup codes,
   `vpf_…` for push, `vpk_…` for passkeys) of the affected enrollment.
3. The **challenge id** (`vpc_…` for push, `vpkc_…` for passkeys) of one
   failing ceremony.
4. The **error code and `details.reason`** from the response envelope,
   verbatim.

That set lets support trace the exact ceremony through the pinned
RP-id / origin, the TTL, and the revocation state without exposing
any enrollment secret — never send a TOTP secret, device private key,
or passkey credential in an escalation.

## See also

* [Verify overview: MFA factors](/verify/overview) — the full factor
  suite contract: endpoints, scope requirements, and enrollment flow.
* [Verify Push API reference](/api-reference/endpoints/verify-push) and
  [Passkey API reference](/api-reference/endpoints/verify-passkey) —
  every endpoint, challenge TTL bounds, and response shapes.
* [Troubleshoot Verify OTP sends and checks](/troubleshooting/verify-otp) —
  the OTP send/check symptom map this page explicitly does not cover.
* [Secure factor events](/api-reference/verify) — the factor-event
  catalog (`totp.create`, `backup_code.verify`, `push.factor.verify`,
  `passkey.challenge.verify`) for driving recovery from your own
  systems.
