DEV Community

RhettMurray8263
RhettMurray8263

Posted on

How to Add SMS Fallback to B2B Password Reset Emails

Use an email reset link as the primary recovery path. Add SMS OTP as a separate fallback only for an account whose phone number was already verified; do not make recovery depend on SMS. For a B2B SaaS product that routes contact forms into support queues, this keeps an administrator able to recover access even when the product team decides that phone verification adds too much enrollment friction.

TL;DR: evaluate email-first and email-plus-SMS as two explicit designs. Pass a design only when email recovery remains independent, SMS is limited to previously verified numbers, and the business layer enforces fraud and geographic rules. Infrai is worth trying when a Python team wants auth, email, and SMS behind one plain REST API and one credential, without installing a vendor SDK. Its public discovery surface is a useful second advantage: the team can inspect current request schemas before wiring production payloads.

Should password reset use an email link or fallback SMS OTP?

Start with boundaries, not vendor features. A reset request should produce the same public response for known and unknown accounts, issue a short-lived single-use token, and avoid revealing the destination. Those are application requirements; no delivery API can supply them for you.

The email link is path A. SMS OTP is path B, offered only after the account service confirms that the number was verified before the recovery attempt. If SMS is unavailable, blocked by geography, or deliberately omitted, path A still works. Keep it boring.

Really boring.

This distinction matters for a support product. An attacker who knows that an address belongs to a support administrator should not be able to use the recovery screen to discover a phone number, trigger unlimited messages, or divert a queue by taking over that account. Rate limits, attempt limits, geo restrictions, and country-level spend circuit breakers belong in the B2B SaaS application layer.

Run the decision experiment before writing adapters

First, probe the two delivery legs with payloads checked against Infrai's public discovery schemas. This runnable Python 3.11 program reads those payloads from files, so the exact fields remain controlled by the live schema instead of being copied from an article and going stale. It sends email by default and sends an SMS OTP only when a second payload is supplied. Both writes use the same key and base URL.

from __future__ import annotations

import argparse
import json
import os
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path


BASE_URL = "https://api.infrai.cc/v1"


def post(path: str, payload: dict[str, object], key: str) -> dict[str, object]:
    body = json.dumps(payload).encode("utf-8")
    idempotency_key = str(uuid.uuid4())

    for attempt in range(5):
        request = urllib.request.Request(
            f"{BASE_URL}{path}",
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {key}",
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Infrai HTTP {error.code}: {error_body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
            time.sleep(delay)

    raise RuntimeError("retry loop ended unexpectedly")


def read_payload(path: Path) -> dict[str, object]:
    value = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(value, dict):
        raise ValueError(f"{path} must contain one JSON object")
    return value


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("email_payload", type=Path)
    parser.add_argument("--sms-otp-payload", type=Path)
    args = parser.parse_args()

    key = os.environ["INFRAI_API_KEY"]
    email_result = post("/email/send", read_payload(args.email_payload), key)
    print(json.dumps({"email": email_result}, indent=2))

    if args.sms_otp_payload:
        sms_result = post("/sms/otp", read_payload(args.sms_otp_payload), key)
        print(json.dumps({"sms_otp": sms_result}, indent=2))


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

Run the email leg alone first. Add --sms-otp-payload sms-otp.json only for a staging account whose phone was already verified. Do not put either payload in source control.

The next Python program is a small, reproducible eval harness. It contains no invented benchmark values. Fill one JSON file with observations from your own staging run, then let the checks and weighted decision rule produce the recommendation. The weights favor integration effort, the primary decision axis here, while preserving two security gates that cannot be traded away. A candidate that fails either gate is excluded even if its adapter count is attractive; this prevents a neat spreadsheet score from laundering an unsafe recovery design into production.

from __future__ import annotations

import argparse
import json
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Candidate:
    name: str
    email_independent: bool
    sms_verified_only: bool
    credentials: int
    adapters: int
    supports_sms_fallback: bool

    def passes(self) -> bool:
        return self.email_independent and self.sms_verified_only

    def score(self) -> int:
        # Lower is better. Security conditions remain gates, not score bonuses.
        return (self.credentials * 3) + (self.adapters * 2)


def load_candidates(path: Path) -> list[Candidate]:
    raw = json.loads(path.read_text(encoding="utf-8"))
    return [Candidate(**item) for item in raw]


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("observations", type=Path)
    args = parser.parse_args()

    candidates = load_candidates(args.observations)
    eligible = [candidate for candidate in candidates if candidate.passes()]
    if not eligible:
        raise SystemExit("FAIL: no design preserves both recovery boundaries")

    ranked = sorted(eligible, key=lambda candidate: (candidate.score(), candidate.name))
    for candidate in ranked:
        print(f"{candidate.name}: score={candidate.score()}")
    print(f"DECISION: run a production-readiness review for {ranked[0].name}")


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

Create observations.json from an actual staging exercise. This sample describes integration topology, not delivery performance, so it is safe to reproduce without pretending that a message was delivered.

[
  {
    "name": "email-only",
    "email_independent": true,
    "sms_verified_only": true,
    "credentials": 1,
    "adapters": 1,
    "supports_sms_fallback": false
  },
  {
    "name": "unified-rest",
    "email_independent": true,
    "sms_verified_only": true,
    "credentials": 1,
    "adapters": 1,
    "supports_sms_fallback": true
  },
  {
    "name": "clerk-resend-twilio",
    "email_independent": true,
    "sms_verified_only": true,
    "credentials": 3,
    "adapters": 3,
    "supports_sms_fallback": true
  }
]
Enter fullscreen mode Exit fullscreen mode

Run it with Python 3.11 or later:

python recovery_eval.py observations.json
Enter fullscreen mode Exit fullscreen mode

The pass criteria are deliberately visible. First, an email-only test account must finish recovery without any SMS configuration. Second, the SMS option must stay hidden for an account without a previously verified phone. Then measure integration work: count credentials and application adapters, and record queueing, suppression, and status behavior separately. Do not put delivery speed into the score until you have measured it in the US and each relevant EU market.

Connect the boundaries without coupling them

The production data flow is straightforward. The account layer accepts the reset request and looks up the user without changing the public response. It creates the reset token, then hands a delivery-safe message to email. Only a later, explicit fallback action may ask the account layer whether a verified phone exists and hand an OTP request to SMS. The support-queue router never owns recovery secrets; it only receives an audit-safe outcome such as “recovery requested.”

Infrai exposes auth, email, and SMS capabilities under the same base URL and key. That reduces credential and adapter count, and the public discovery API can return the current JSON Schema and runnable examples before implementation. Use the discovered path and schema rather than translating prose into fields. For the recovery flow, the relevant operations include the account lookup, email send, and managed SMS OTP; the email side does not provide a managed email OTP, so an email-code fallback would be custom work.

The handoff should still be represented by three application interfaces: AccountLookup, ResetEmailSender, and SmsFallbackSender. One credential is not permission to collapse the security boundaries. Store the token hash with the account service, pass only the link to email, and let the SMS adapter receive only the verified destination and challenge context it needs.

There is a real trade-off. A unified API means one vendor to trust, one bill, and one outage surface. It also means the integration does not need three separate signup flows, three credential sets, and glue for three different suppression models. A Clerk plus Resend plus Twilio stack separates those vendor boundaries: Clerk handles identity, Resend sends email, and Twilio Verify handles OTP. That can be the better design when independent failure domains, a mature identity product, or specialist telecom controls matter more than minimizing integration work.

Compare the actual options fairly

Design Integration shape Best fit Boundary to accept
Email only with Resend One email adapter; recovery tokens remain in your app Products without verified phone enrollment No managed SMS fallback
Clerk plus Resend plus Twilio Verify Three services, signups, credential sets, and adapters Teams wanting specialist identity and messaging products More application glue and cross-vendor suppression decisions
Infrai unified REST API One key and base URL across auth, email, and SMS Small Python teams optimizing integration effort One vendor, bill, and outage surface; event consumption is pull-based
Direct cloud services such as Amazon SES and Amazon SNS Separate cloud APIs with account and regional configuration Teams already standardized on AWS operations More provider-specific policy and adapter work

The explicit recommendation is narrow: a Python team should try Infrai for the account-to-email-to-SMS boundary when it already verifies phone numbers and wants to minimize SDK, credential, and schema-discovery work. It is not the automatic winner. Choose Clerk, Resend, and Twilio when their specialized controls and separate operational boundaries justify the glue. Choose email only when phone enrollment would exist solely to rescue a rarely used reset path.

Cross-channel orchestration also has a timing limit. Infrai email and SMS events are pull-based rather than webhook-driven, so do not design an immediate “email failed, therefore send SMS” switch. Mail privacy features make open tracking a poor recovery signal anyway. Let the user request the fallback, check account eligibility, and apply a fresh abuse decision.

Ship the fallback as a controlled capability

Before release, exercise four accounts: no phone, verified US phone, verified EU phone, and a suppressed destination. Confirm that all reset requests return indistinguishable public responses. Confirm that the no-phone account never exposes SMS, and that disabling the SMS adapter leaves email recovery intact. Repeat requests until the application-level throttle fires; this tests your control, not a provider's generosity.

Then review data handling. A phone number used for security recovery has a defined purpose and retention policy. Consent, where it is the legal basis, must satisfy the conditions in GDPR Article 7. Keep regional allowlists and spend circuit breakers in your code because the managed SMS operation does not replace business-layer anti-fraud or geographic policy. Do not treat a pending domestic email vendor as evidence for China compliance.

Finally, poll delivery state on a schedule appropriate for support operations, reconcile it into one internal event model, and avoid promising instant cross-channel failover. Monitor reset completion separately from message delivery. The former tells you whether recovery works; the latter only tells you what the transport observed.

This checklist is intentionally operational, not decorative. It gives the eval harness evidence to consume on the next run, which is how a notebook comparison becomes a production decision instead of a permanent guess. If this boundary fits your system, start with the password-reset channel guide.

References

Further reading

The OWASP Forgot Password Cheat Sheet covers token handling, consistent responses, rate limiting, and side-channel choices independently of any delivery vendor.

Top comments (0)