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

# Add SMS + TOTP 2FA to your app's login flow

> Build a login-time 2FA challenge that sends an OTP over SMS with a TOTP backup factor, persists a verified factor on the user, and gates risky attempts with SIM-swap and channel fraud checks.

# Add SMS + TOTP 2FA to your app's login flow

This recipe wires a login-page second factor for a SaaS application. After the user passes your primary authentication (password, SSO, or passkey), your backend challenges them with an SMS OTP. If SMS is unavailable — poor coverage, carrier reject, or a SIM-swap flag — the user can fall back to a TOTP authenticator app. The result is a single `verified_factor` record you persist against the user session before issuing your own access token.

## 1. When to use this recipe

Use OTP-at-login when you already know who the user is and only need to prove they still control the phone number on the account. This is different from **number verification at signup**, where the goal is to bind a phone number to a new account before the account exists. In signup binding you send the code first and create the user only after approval; in login 2FA the user record is already present and you are gating session issuance.

| Flow | Goal | Typical trigger |
| - | - | - |
| Login 2FA | Prove possession of a registered factor before issuing a session | Primary auth succeeded |
| Number verification | Bind a phone number to an identity during onboarding | Signup or settings change |
| Step-up | Re-verify before a sensitive action (transfer, deletion) | High-risk operation inside an active session |

## 2. Pick the channel mix

For most SaaS logins the right mix is:

* **SMS first** — works on every phone, no app install required.
* **TOTP as backup** — no carrier dependency, survives SIM swap and roaming outages.
* **Push as fast path** — for users who already enrolled a device, a push approval is faster than typing a code. Skip it on first enrollment because there is no device key yet.

This recipe covers SMS primary + TOTP backup. Push and passkeys follow the same enrollment pattern; see the [Verify factor suite guide](/guides/verify-factor-suite) for the full lifecycle.

## 3. Configure a Verify profile

Create a profile that pins code length, TTL, attempt budget, and the ordered channel list. The profile keeps your login flow consistent across every send. Use `POST /api/v1/verify/profiles`:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/profiles" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "SaaS login 2FA",
    "channels": ["sms", "voice"],
    "codeLength": 6,
    "expirySeconds": 300,
    "maxAttempts": 3,
    "rateLimitPerHour": 5,
    "templates": {
      "sms": "{{app_name}} login code: {{code}}. Valid for {{expiry_minutes}} minutes.",
      "voice": "Your {{app_name}} login code is {{code}}. I repeat, {{code}}."
    }
  }'
```

Response:

```json theme={null}
{
  "data": {
    "id": "vprof_7a1c0e2b…",
    "name": "SaaS login 2FA",
    "channels": ["sms", "voice"],
    "status": "active"
  }
}
```

Store `id` as `profile_id`. If `voice` is in the chain, the profile must include a `templates.voice` script containing `{{code}}`.

## 4. Server-side flow: start the challenge

After primary auth succeeds, call `POST /api/v1/verify/send` from your backend. Never call it from the browser — the API key must stay server-side.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/send" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "channel": "sms",
    "profile_id": "vprof_7a1c0e2b…"
  }'
```

Response:

```json theme={null}
{
  "data": {
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "status": "pending",
    "channel": "sms",
    "expires_at": "2026-10-10T14:05:00.000Z"
  }
}
```

Persist `verification_id` in your session store, keyed to the authenticated user. Show the code-entry UI. If your profile lists `voice` as a fallback, the async fallback engine will advance to voice automatically if SMS fails or times out, re-delivering the same code.

### Optional device fingerprinting

Send `device_token` or a stable `device_id` alongside the request if you want to correlate later fraud checks. The send body accepts `device_token` for channel `sna`; for ordinary SMS sends you can store the association server-side from the request metadata.

## 5. Confirm the code

Collect the code from your UI and POST it to `POST /api/v1/verify/check`:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/check" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "code": "482910"
  }'
```

On success:

```json theme={null}
{
  "data": {
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "status": "approved",
    "attempts_remaining": 0
  }
}
```

Treat the user as 2FA-verified and issue your session.

### Handle expiry and attempt-budget errors

| Error | HTTP | What to do |
| - | - | - |
| `EXPIRED_TOKEN` | 410 | `expires_at` passed. Start a new verification rather than accepting the old code. |
| `VALIDATION_ERROR` | 422 | Wrong code. The response includes `attempts_remaining`; surface it in the UI. |
| `RATE_LIMIT_EXCEEDED` | 429 | Per-recipient or per-key cap tripped. Honor `Retry-After`. |

After `maxAttempts` wrong guesses the verification moves to `failed` and emits `verification.failed`.

## 6. Wire the backup factor: TOTP

When SMS fails, offer the user a TOTP authenticator app as a fallback. TOTP bypasses SMS-only failure modes because the secret is already shared between server and app; no carrier, no SIM, and no delivery latency is involved.

### Enroll TOTP at setup time

Enroll with `POST /api/v1/verify/factors/totp`:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/factors/totp" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountName": "ada@example.com",
    "label": "Ada iPhone"
  }'
```

Response:

```json theme={null}
{
  "data": {
    "factor_id": "vftotp_3f8b1c9d2e",
    "secret": "JBSWY3DPEHPK3PXPB63DEH7W5S6XKPKP",
    "otpauth_uri": "otpauth://totp/Orbit%20by%20Devotel:ada%40example.com?secret=JBSWY3DPEHPK3PXPB63DEH7W5S6XKPKP&issuer=Orbit%20by%20Devotel&digits=6&period=30",
    "digits": 6,
    "period": 30,
    "algorithm": "SHA1",
    "recovery_codes": ["K3HQF-9PWMB-7XTZK", "…"]
  }
}
```

Render `otpauth_uri` as a QR code. Persist `factor_id` against the user record. Save the recovery codes in your password manager or recovery flow; they are surfaced **once**.

### Verify TOTP at login time

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/factors/totp/vftotp_3f8b1c9d2e/verify" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "482913" }'
```

The same endpoint accepts a recovery code. On approval, set `verified_factor = totp` on the session and issue your access token.

### Regenerate recovery codes

If the user runs low, call:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/factors/totp/vftotp_3f8b1c9d2e/recovery-codes/regenerate" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

The old set is invalidated immediately.

## 7. SIM-swap and channel fraud gates

Before the SMS is sent, Orbit runs a SIM-swap pre-flight. If the number was swapped recently, the send returns a synchronous `403 SIM_SWAP_DETECTED` with `last_swap_date` and `block_window_hours`. No verification row is created and no `verification.failed` webhook fires. In that case, refuse the SMS challenge and fall straight to TOTP.

You can also dip the number before sending with `POST /api/v1/verify/fraud-score`:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/fraud-score" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone_number": "+14155552671" }'
```

Use the returned `risk_level` and `recommendation` to decide whether to send SMS, force TOTP, or block the attempt. Log the recommendation and the reason codes in your audit trail; they appear in the Verify console under the fraud-review drawer.

For the composite operator fusion (SIM-swap + port-event + silent-network-auth possession), use `POST /verify/fraud-gate`.

## 8. Error catalogue and retry policy

| Condition | Response | Retry policy |
| - | - | - |
| SMS channel transient skip | `channel_transient_skip` in status / webhook | Back off with jitter and let the fallback chain advance to voice. |
| Fraud blocked | `fraud_blocked` or `403 SIM_SWAP_DETECTED` | Hard-fail the SMS path; offer TOTP or block the login. |
| Carrier reject | `undelivered` / `rejected` webhook | The async fallback engine advances; do not resend manually. |
| Expired code | `EXPIRED_TOKEN` (410) | Start a new verification. |
| Exhausted attempts | `verification.failed` | Lock the factor briefly or offer account recovery. |

A `channel_transient_skip` means the provider failed in a way that may recover; a `fraud_blocked` verdict means the risk signal itself refuses the channel. Do not retry fraud blocks.

## 9. Test in sandbox

Use a sandbox key (`dv_test_sk_…`) and the [magic numbers](/sandbox/magic-numbers) to rehearse outcomes deterministically. The trailing digit of the recipient number selects the simulated terminal status.

| Test number | Outcome | What to assert |
| - | - | - |
| `+14155550102` | Delivered | `/check` with the code returns `approved`. |
| `+14155550103` | Undelivered | Fallback to voice advances; if no fallback, surface "try TOTP." |
| `+14155550104` | Failed | No `sent` webhook; offer TOTP. |
| `+14155550105` | Expired | `/check` returns `EXPIRED_TOKEN` after the simulated TTL. |

In sandbox you can also pass `custom_code` on `/verify/send` to fix the OTP digits for integration tests. Live keys reject `custom_code` with `403 CUSTOM_CODE_FORBIDDEN`.

## 10. Production checklist

* [ ] Store `verification_id` server-side, not in the browser.
* [ ] Enforce a short TTL for login codes (300 seconds is typical).
* [ ] Cap attempts at 3 and surface `attempts_remaining` to the UI.
* [ ] Enroll TOTP during settings setup, not during a failing login.
* [ ] Log every `fraud-score` / `fraud-gate` decision.
* [ ] Subscribe to `verification.approved`, `verification.failed`, and `verification.fallback_exhausted` for audit workers.

## See also

* [Verify factor suite](/guides/verify-factor-suite) — push, passkey, backup codes, and magic links.
* [Verify profiles and fallback chains](/guides/verify-fallback-chains) — the full channel-fallback theory and profile fields.
* [Verify approvals and two-officer control](/guides/verify-approvals-two-officer) — for deployments that require dual authorization.
* [Verify integration without our SDK](/guides/verify-no-sdk) — the plain-HTTP send/check flow and full error matrix.
* [Verify API reference](/api-reference/endpoints/verify) — every endpoint, including profile CRUD and factor suites.
* [Sandbox magic numbers playbook](/guides/sandbox-magic-numbers-playbook) — the full catalog of test numbers.


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