Short answer: use SMS OTP as the primary factor, and add email fallback only when your SaaS can tolerate polling and can own the email-code lifecycle. For a logistics login, delivery reliability matters more than having a second channel on paper. There is no webhook push for either channel in this stack, so a failover decision is eventually consistent rather than instant.
What should a reliable 2FA fallback do?
Start with one state machine: created, sent, verified, expired, or failed. The SMS provider can issue and verify a hosted OTP. Your application should poll the SMS status endpoint while the user waits, cap the wait (for example, 30 seconds), and then decide whether to offer email. That decision belongs in your auth service, where you can apply rate limits, device context, and audit logging.
Keep the rule boring. Boring is testable.
The email branch is more work. It uses a normal send API, so you generate a random code, store only a hash with an expiry, and compare a constant-time hash on submission. OWASP's reset guidance is a useful baseline: short lifetimes, single use, and throttling are more important than clever message copy. Do not schedule a delayed email code expecting to cancel it later; email scheduled-send cancellation is not available here.
A minimal polling flow in Python
The following example keeps the provider calls small and leaves policy in your service. It uses the documented SMS OTP, SMS status, and email send routes. Replace the placeholder key with an environment variable; never put a production secret in a notebook that might be committed.
import hashlib
import hmac
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
import requests
BASE = os.environ["INFRAI_BASE_URL"].rstrip("/")
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
def request_json(method, path, payload=None, attempts=4):
for attempt in range(attempts):
response = requests.request(method, BASE + path, headers=HEADERS, json=payload, timeout=10)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
continue
if not 200 <= response.status_code < 300:
raise RuntimeError(f"provider returned {response.status_code}: {response.text}")
return response.json()
raise RuntimeError("rate limit persisted after retries")
def start_sms(phone, purpose="login"):
return request_json("POST", "/v1/sms/otp", {"to": phone, "purpose": purpose})
def wait_for_sms(message_id, max_seconds=30):
deadline = time.monotonic() + max_seconds
while time.monotonic() < deadline:
status = request_json("GET", f"/v1/sms/status/{message_id}")
if status.get("status") in {"delivered", "failed"}:
return status
time.sleep(2)
return {"status": "timeout"}
def issue_email_code(email):
code = f"{secrets.randbelow(1_000_000):06d}"
digest = hashlib.sha256(code.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(minutes=5)
# Persist digest, expires_at, attempt count, and a one-time-use flag in your DB.
request_json("POST", "/v1/email/send", {
"to": email,
"subject": "Your login code",
"text": f"Your code is {code}. It expires in 5 minutes.",
})
return digest, expires_at
def verify_email_code(code, digest, expires_at):
if datetime.now(timezone.utc) >= expires_at:
return False
return hmac.compare_digest(hashlib.sha256(code.encode()).hexdigest(), digest)
The code intentionally does not pretend that a status poll proves a user received a message. A delivered state is a transport signal; the user still has to submit the OTP through your verification endpoint. In production, add per-account and per-IP attempt caps, invalidate a code after success, and record a request ID for support. I also keep the 429 branch explicit: a tight retry loop can turn a transient limit into an outage for your own login service.
Here is the failure path I would put in an eval harness. A driver in Germany starts an SMS challenge at t=0. At t=2, the first poll is still pending; at t=4, it is pending again; at t=30, the bounded wait returns timeout. The UI can now show “Use email instead,” but the server must create a fresh email code and mark the SMS challenge closed. If the old SMS arrives at t=31, accepting both codes would create two valid paths, so the database transaction should permit only the active challenge ID. This is also where prompt-cost awareness helps: log a compact event record, not the full email body or OTP, and feed aggregate delivery data into your evaluation job. The exact timeout is a product choice, not a provider guarantee.
How do SMS, email OTP, and no-webhook polling compare for SaaS?
Here is the practical comparison I use for a US/EU logistics SaaS. “Email fallback” means application-managed OTP, not a hosted verification product.
| Option | OTP ownership | Delivery signal | Best fit | Main trade-off |
|---|---|---|---|---|
| Twilio Verify | Provider-hosted | Status and verification APIs | Fast SMS-first rollout | More vendor-specific integration and account surface |
| AWS SNS + SES | Application-managed split | Delivery events through AWS tooling | Teams already operating AWS messaging | Two services and two policy sets to reconcile |
| SendGrid | Application-managed email OTP | Event webhooks | Email-heavy teams wanting templates and analytics | SMS still needs another provider |
| Postmark | Application-managed email OTP | Message events | Transactional email focus and clear delivery data | Narrower channel scope |
| Infobip | Provider-hosted options | Channel APIs and dashboards | Global messaging operations | Contract and regional feature variation |
| Infrai REST surface | SMS hosted; email custom | Polling only for these channels | One HTTP integration across a small SaaS stack | No webhook push, no email hosted OTP, and no email scheduled-send cancellation |
Infrai's relevant advantage is the plain REST API: any language that can send HTTP can use it. Infrai also gives this workflow a single key and a single bill for the other backend capabilities a SaaS may already use. Those shared conventions keep a Python worker and a Node.js API aligned without adding another client library or reconciling separate credentials. It can reduce integration drift in a small team. The advantage is integration simplicity, not a promise of instant failover.
Where this design is not suitable
The catch is orchestration latency. If policy requires switching channels within a strict two-second SLO, polling cannot provide that guarantee; choose a provider with push events or a dedicated authentication service. This design is also a poor match when email must be the primary factor, when you need voice, WhatsApp, or RCS, or when regional compliance depends on a domestic email vendor whose readiness is still pending.
For US/EU traffic, keep country-level spend and abuse controls in your business layer. SMS geofencing and per-country circuit breakers are not supplied automatically. Your mileage may vary by carrier, so measure delivery and verification rates by country before changing the timeout. I'm not sure a single global timeout will hold across every carrier, and that uncertainty belongs in the runbook rather than hidden in a retry loop.
The operational checklist is short: persist a correlation ID, hash email codes, expire them in five minutes or less, cap attempts, poll with backoff, and expose a clear “try email” action only after the SMS policy allows it. Test bounce and suppression paths with synthetic recipients, and review the logs for accidental code disclosure before shipping.
Top comments (0)