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 singleverified_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.
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. UsePOST /api/v1/verify/profiles:
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, callPOST /api/v1/verify/send from your backend. Never call it from the browser — the API key must stay server-side.
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
Senddevice_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 toPOST /api/v1/verify/check:
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 withPOST /api/v1/verify/factors/totp:
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
verified_factor = totp on the session and issue your access token.
Regenerate recovery codes
If the user runs low, call: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 synchronous403 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:
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_idserver-side, not in the browser. - Enforce a short TTL for login codes (300 seconds is typical).
- Cap attempts at 3 and surface
attempts_remainingto the UI. - Enroll TOTP during settings setup, not during a failing login.
- Log every
fraud-score/fraud-gatedecision. - Subscribe to
verification.approved,verification.failed, andverification.fallback_exhaustedfor audit workers.
See also
- Verify factor suite — push, passkey, backup codes, and magic links.
- Verify profiles and fallback chains — the full channel-fallback theory and profile fields.
- Verify approvals and two-officer control — for deployments that require dual authorization.
- Verify integration without our SDK — the plain-HTTP send/check flow and full error matrix.
- Verify API reference — every endpoint, including profile CRUD and factor suites.
- Sandbox magic numbers playbook — the full catalog of test numbers.