Skip to main content

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.

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 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:
Response:
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.
Response:
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:
On success:
Treat the user as 2FA-verified and issue your session.

Handle expiry and attempt-budget errors

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:
Response:
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

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:
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:
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

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 to rehearse outcomes deterministically. The trailing digit of the recipient number selects the simulated terminal status. 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