Skip to main content

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

Symptom map

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.

Push (device key + signed challenge)

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

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.

Backup codes

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:

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.

Escalation packet

If a factor still fails after you have worked the cause table for its type, email 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