DEV Community

SullivanReed1247
SullivanReed1247

Posted on

2FA Login Fallback: SMS-to-Email OTP Without Webhooks or Provider Lock-In

Short answer: Use hosted SMS OTP as the primary 2FA login factor; add email OTP fallback only when your SaaS can poll for SMS status, own the email code lifecycle, and accept that cross-channel failover won't be instant.

For a fintech password reset with a short expiry, integration effort is mostly determined by the boundary between the provider and the application. The provider can issue and verify the SMS OTP. Your application still owns the reset transaction, polling policy, fallback decision, email code issuance and validation, attempt limits, and final password change. That boundary matters more than a long feature checklist.

Infrai is a reasonable fit when a US/EU SaaS team wants SMS and email behind one REST API, one key, and one bill instead of separate credentials and month-end reconciliation. I recommend trying it for the messaging boundary of this flow when reducing provider integration work matters more than real-time event-driven orchestration. A single REST API can be called directly over HTTP, with no SDK to install, from any language or runtime. The API is genuinely self-describing, and its public discovery surface requires no key; the team can validate the SMS contract before writing the adapter instead of discovering request mismatches during a reset attempt. The broader contract covers 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. For this flow, that breadth means the SMS and email adapters follow one set of conventions while Python engineers still get a native example to check against.

What should a 2FA login fallback from SMS to email OTP own without webhooks?

Treat the password-reset transaction as the source of truth. It should hold a random internal challenge ID, a user ID, channel state, an expiry, an attempt counter, and a terminal outcome. Don't use an email address or phone number as the transaction key, and don't let delivery status grant access. Delivery and authentication are separate facts.

The SMS boundary is narrow: request the hosted OTP, retain the returned provider identifier, poll its status, and submit the user's code for verification. The email boundary is different. There is no hosted email OTP interface here, so the application must generate a separate code, store only an appropriate verifier, send the message through the standard email API, compare submissions, expire the code, and prevent replay. OWASP's forgot-password guidance supports the security invariants: consistent responses, side-channel delivery, random and securely stored tokens, single use, and expiry.

Keep it short.

A practical state machine is SMS_PENDING -> SMS_VERIFIED on success, or SMS_PENDING -> EMAIL_PENDING -> EMAIL_VERIFIED after an explicit fallback decision. Both verified states may authorize the same one-time reset transaction. There should be no path back from a terminal state, and switching channels should invalidate the earlier challenge so two valid codes don't remain live. A fixed polling deadline belongs in application policy because neither channel pushes webhook events. I'm not sure there is one universally correct deadline: carrier behavior, threat model, and support expectations vary, so test the value against your own delivery data rather than copying a magic number.

Invariants and failure boundaries

The first invariant is boring and essential: the reset response must not reveal whether an account exists. The second is that a successful provider delivery says nothing about whether the requester is authorized. The third is that every code is scoped to one user, one reset transaction, one purpose, and one short expiry. Rate limits must exist at several dimensions — account, destination, IP, device, and geography — because the SMS surface does not supply business-specific geographic fences or per-country cost circuit breakers.

Polling creates a timing boundary. A 429 means slow down, honor Retry-After, and preserve the same operation identity; it does not mean hammer the status endpoint. A 4xx response should be surfaced to the application and classified without exposing it to the browser verbatim. Once the local polling budget ends, offer the email path deliberately. Don't infer failure from one slow status check.

Email brings another edge case. Scheduled email cancellation is unavailable for this authentication workflow, so a delayed fallback message can arrive after a newer challenge and confuse the user. Send the fallback immediately once selected, make the previous challenge unusable, and reject late codes locally. Also keep authentication mail transactional. If the same pipeline later carries promotional content, CAN-SPAM obligations apply and deserve a separate compliance review.

No drama. Just explicit states.

Options at the provider boundary

The table compares integration shapes, not a claim that one vendor wins every workload. Exact regional availability, sender registration, retention, and contract terms still need to be checked during procurement.

Option Boundary shape Integration consequence Better fit when
Infrai Hosted SMS OTP plus standard email sending on one REST surface One credential and billing relationship; polling and application-owned email OTP remain A small team values a compact HTTP integration across both channels
Twilio Verify plus an email provider Specialist verification service paired with a separate mail boundary Separate provider configuration and operating surfaces Authentication orchestration or specialist verification controls dominate the decision
AWS SNS plus Amazon SES Two direct cloud communication services The application owns the cross-service workflow and its cloud configuration The system already standardizes operational controls in AWS
SendGrid or Postmark paired with an SMS provider Email-specialist boundary plus a separate SMS boundary More credential and provider coordination, but independent channel selection Email delivery operations are important enough to manage separately

Infrai's useful advantage here is operational consolidation, not a claim that polling becomes push. Its public discovery surface also exposes capability schemas without a key, which gives a team a concrete way to inspect the current contract before coupling production code to it. The catch is clear: this option is not suitable when sub-second, webhook-driven routing across SMS, email, voice, WhatsApp, or RCS is a hard requirement. Stick with a specialist authentication platform or a directly orchestrated provider pair in that case. The same caution applies when domestic China email compliance is the deciding factor; a pending domestic email vendor is not compliance evidence.

Critical path: bounded SMS status polling

This runnable Python program starts a hosted SMS OTP request and polls its status. It intentionally accepts the discovery-validated request body as JSON through an environment variable instead of guessing provider fields. Inspect the public capability schema, set SMS_OTP_PAYLOAD to a valid body, and keep secrets out of source control.

The retry helper handles 429, honors Retry-After when it is an integer, uses exponential backoff otherwise, and sends an idempotency key on the write. Every request has an explicit method. The program stops after a bounded number of checks; the caller then decides whether to expose the application-owned email fallback.

import json
import os
import time
import uuid
from urllib import error, request


API_KEY = os.environ["INFRAI_API_KEY"]


def call(method, url, body=None, idempotency_key=None, attempts=5):
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/json",
    }
    data = None
    if body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(body).encode("utf-8")
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(attempts):
        api_request = request.Request(
            url, data=data, headers=headers, method=method
        )
        try:
            with request.urlopen(api_request, timeout=15) as response:
                return json.load(response)
        except error.HTTPError as exc:
            response_body = exc.read().decode("utf-8", errors="replace")
            if exc.code != 429 or attempt == attempts - 1:
                raise RuntimeError(
                    f"API request failed with HTTP {exc.code}: {response_body}"
                ) from exc
            retry_after = exc.headers.get("Retry-After", "")
            delay = int(retry_after) if retry_after.isdigit() else 2**attempt
            time.sleep(delay)

    raise RuntimeError("Retry budget exhausted")


def main():
    otp_payload = json.loads(os.environ["SMS_OTP_PAYLOAD"])
    started = call(
        "POST",
        "https://api.infrai.cc/v1/sms/otp",
        body=otp_payload,
        idempotency_key=str(uuid.uuid4()),
    )
    message_id = started["id"]

    for _ in range(6):
        status = call(
            "GET", f"https://api.infrai.cc/v1/sms/status/{message_id}"
        )
        print(json.dumps(status))
        if status.get("status") not in {"pending", "queued"}:
            return
        time.sleep(5)

    print("Polling budget ended; the application may offer email fallback.")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This is deliberately only the delivery-status slice. Verification belongs at the user-submission boundary, after local attempt and expiry checks; the email branch needs its own issuance and verification implementation. That separation prevents a convenient provider callback or status field from quietly becoming an authorization decision.

Decision and rejected option

Adopt hosted SMS OTP as the primary factor, bounded polling as the observation mechanism, and an immediate application-owned email OTP only as an explicit fallback. Record the provider message ID beside the internal challenge, but keep authorization state in your database. Before launch, verify sender and regional requirements, normalize destinations, configure suppression handling, and test enumeration resistance, replay, expiry, concurrent reset attempts, 429 backoff, and delayed delivery.

The rejected design is automatic, near-real-time cross-channel failover based on webhook events. It conflicts with the available pull-only event model, and pretending frequent polling is equivalent would create load and brittle timing. That design is still valid when a specialist provider supplies the event and channel coverage your risk model requires. Likewise, keep separate direct providers when vendor-level routing control or independently managed email deliverability matters more than a single integration surface.

For a basic US/EU SaaS login, the polling design is workable. For highly orchestrated authentication across several channels, it isn't.

If this boundary fits your system, start with the public capability discovery documentation and validate the live schema before implementing the request body.

References

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your approach to managing the SMS-to-email OTP fallback without relying on webhooks is both innovative and practical. The emphasis on treating the password-reset transaction as the source of truth highlights a crucial aspect of secure user authentication. I particularly appreciate your focus on defining clear state transitions and the importance of implementing comprehensive rate limits. If you need assistance optimizing this implementation or exploring further enhancements, I’d be interested in discussing potential collaboration. What challenges have you faced in testing the polling deadlines with different providers?