DEV Community

matsjohansson6547
matsjohansson6547

Posted on

2FA Login Fallbacks Explained: 5 Rules for Reliable SMS and Email OTP

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)
Enter fullscreen mode Exit fullscreen mode

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.

References

Top comments (0)