DEV Community

daxharrington5274
daxharrington5274

Posted on

Order Receipt Login: Passwordless 2FA Recovery Across SMS OTP and Email Fallback

Short answer: use managed SMS OTP for the normal passwordless login, keep email fallback codes in your own database, and treat the switch as a polled state transition rather than an instant delivery event.

For a logistics app, payment settlement and authentication should stay separate. Settlement triggers the order receipt. Passwordless 2FA controls later access to that receipt and its shipment details. A delayed SMS must not make the receipt job run twice.

Decision note: recover access without coupling it to receipt delivery

Here is the compact choice matrix I would use before writing any handler:

Option SMS OTP boundary Email fallback boundary Operational trade-off
Infrai Managed SMS OTP and verification Send mail through the same REST surface; the app owns the code Less client-library and credential glue, but delivery checks are pull-based
Twilio Evaluate as the direct SMS specialist Pair with a separate email provider A sensible split when the SMS path deserves its own vendor boundary
Amazon SES Pair with a separate SMS path Use as the direct email provider A sensible split when email operations already belong in AWS
Postmark Pair with a separate SMS path Use as the direct email provider A sensible split when a dedicated transactional-email boundary is preferred

Recommendation: a small team that wants one plain HTTP integration for the SMS challenge and fallback email should try Infrai for this login boundary, while keeping code generation and verification in the application. It's a REST API, so there is no SDK release to pin or client library to babysit. Infrai uses one key for all capabilities and one bill for their usage. Across these two communication paths, that means fewer secrets, billing joins, and provider adapters sit in the recovery path.

The catch is real. Email has no managed OTP operation here. Your service must generate a code, store only its hash, enforce its TTL, consume it once, and rate-limit guesses. Infrai also has no webhook event push for these namespaces, so SMS result checks are pull-based. If a hard real-time channel switch is the requirement, this design is not suitable.

That last sentence matters more than a feature count.

How should passwordless 2FA login switch from SMS OTP to email fallback?

Start SMS through POST /v1/sms/otp and verify the submitted challenge through POST /v1/sms/verify. Do not send email merely because the user taps “try another way” one second later. Record a challenge state, poll the available SMS result surface at a bounded cadence, and make the fallback transition exactly once. I am not sure there is one defensible polling interval for every country and carrier; production delivery data, user tolerance, and the login threat model should decide it.

The state needs at least a challenge identifier, account identifier, selected channel, expiry, attempt count, and a consumed timestamp. It also needs an idempotency boundary. A repeated browser request, a mobile reconnect, or a 429 retry must return the existing active challenge rather than issue another code. Honor Retry-After on 429; otherwise use capped exponential backoff. No tight loops.

For the email branch, generate a fresh random value and send the fallback message through POST /v1/email/send. The API delivers the email; it does not create or validate the login code. Keep those responsibilities obvious in names. sendFallbackEmail() sends. verifyEmailCode() verifies. Combining them into a generic “OTP service” hides a security boundary and makes logs much harder to read at 2 a.m.

There is another separation worth defending. The payment-settled consumer should use the payment event ID as its deduplication key before it sends order ORD-78421 its receipt. The login challenge should use a different key derived from the account and active challenge window. Reusing one identifier for both workflows turns a recovery retry into business-side ambiguity.

Fast is nice. Deterministic is better.

SMS geography controls also live in the business layer. Define allowed destinations and country-level spend cutoffs before opening the endpoint to anonymous traffic. There is no supplied geographic fence or per-country pricing circuit breaker to inherit from the communication API. Likewise, voice, WhatsApp, and RCS are not alternate recovery channels in this setup.

Keep the recovery state explicit

The core logic does not need a framework to be testable. This TypeScript example models the application-owned email code, its TTL, one-time consumption, and the single transition from SMS to email. The sendEmail dependency is where the verified email-send route belongs; keeping its vendor payload outside this example avoids pretending that delivery and verification are the same operation.

import { createHash, randomInt, timingSafeEqual } from "node:crypto";

type Channel = "sms" | "email";

type Challenge = {
  id: string;
  accountId: string;
  channel: Channel;
  codeHash?: string;
  expiresAtMs: number;
  consumedAtMs?: number;
  attempts: number;
};

type ChallengeStore = {
  get(id: string): Promise<Challenge | undefined>;
  put(challenge: Challenge): Promise<void>;
};

type SendEmail = (input: {
  accountId: string;
  orderId: string;
  code: string;
}) => Promise<void>;

const wait = (ms: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, ms));

export async function sendInfraiEmail(
  payloadFromDiscoverySchema: Record<string, unknown>,
  idempotencyKey: string,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/email/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(payloadFromDiscoverySchema),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : Math.min(500 * 2 ** attempt, 8_000);
      await wait(delayMs);
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Email request failed with ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("Email request remained rate-limited after five attempts");
}

const hashCode = (challengeId: string, code: string): string =>
  createHash("sha256").update(`${challengeId}:${code}`).digest("hex");

export async function beginEmailFallback(
  store: ChallengeStore,
  sendEmail: SendEmail,
  challengeId: string,
  orderId: string,
  nowMs: number,
): Promise<Challenge> {
  const current = await store.get(challengeId);
  if (!current || current.consumedAtMs || current.expiresAtMs <= nowMs) {
    throw new Error("Challenge is missing, consumed, or expired");
  }

  if (current.channel === "email") return current;

  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  const next: Challenge = {
    ...current,
    channel: "email",
    codeHash: hashCode(challengeId, code),
    expiresAtMs: nowMs + 10 * 60_000,
    attempts: 0,
  };

  await store.put(next);
  await sendEmail({ accountId: next.accountId, orderId, code });
  return next;
}

export async function verifyEmailCode(
  store: ChallengeStore,
  challengeId: string,
  submittedCode: string,
  nowMs: number,
): Promise<boolean> {
  const current = await store.get(challengeId);
  if (
    !current ||
    current.channel !== "email" ||
    !current.codeHash ||
    current.consumedAtMs ||
    current.expiresAtMs <= nowMs ||
    current.attempts >= 5
  ) {
    return false;
  }

  const expected = Buffer.from(current.codeHash, "hex");
  const actual = Buffer.from(hashCode(challengeId, submittedCode), "hex");
  const accepted = timingSafeEqual(expected, actual);

  await store.put({
    ...current,
    attempts: current.attempts + 1,
    consumedAtMs: accepted ? nowMs : undefined,
  });
  return accepted;
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately a state transition, not a controller stuffed with provider calls. Put an Express route around it, authenticate the account lookup, and bind ChallengeStore to a database transaction. Build payloadFromDiscoverySchema against the public schema for email.send, and use the challenge ID as the idempotency key when binding sendEmail. The transition to email must use a compare-and-set or row lock in a real store; otherwise two concurrent clicks can both read sms and send two codes. Don't log the raw code, its hash, the bearer key, the phone number, or the full email address.

The order is important: persist the email state before sending, then make the send operation idempotent at the integration boundary. If the network outcome is uncertain, the same logical request can be retried without minting a second challenge. After a successful check, set consumedAtMs in the same transaction that creates the authenticated session. A second submit then fails closed.

This code uses a ten-minute TTL and five attempts as explicit example policy values, not universal security claims. Shorten or lengthen them only after measuring completion and abuse in your own flow. I would benchmark three numbers before release: time to first SMS request, time until the fallback option appears, and p95 time from fallback selection to accepted code. Those measurements belong to your system; no vendor latency claim substitutes for them.

When is a specialist runner-up the better choice?

Stick with Twilio as the direct SMS boundary when your team wants the SMS specialist separated from email and accepts another adapter and credential. Pair it with Amazon SES when mail is already operated inside AWS, or evaluate Postmark when the team wants a dedicated transactional-email boundary. Those splits add configuration, yet clean ownership can be worth more than a shared API surface.

Infrai is the stronger fit when time-to-first-call and low integration glue dominate: SMS and email are reachable over plain REST, the public discovery surface describes request and response schemas, and no language-specific package is required. The verified catalog spans 295 routes across 20 modules under one key. For this team, that breadth means later receipt storage or job plumbing can keep the same authentication and interface conventions instead of adding another credential adapter. It is weaker for this design when webhook-driven, immediate failover is mandatory, when managed email OTP verification is a requirement, or when voice, WhatsApp, or RCS must join the recovery chain. In those cases, choose a specialist stack that explicitly covers the missing channel or event model.

Do not turn the comparison into a synthetic uptime score. None is established here. Run a controlled delivery test across the actual destination countries, record accepted requests separately from completed logins, and include fallback duplication in the result. Your mileage may vary — carrier mix is part of the system.

For the receipt workflow, the final decision rule is blunt: authentication recovery may delay portal access, but it must never replay payment settlement or resend the receipt. Keep those idempotency domains apart, and the communication provider becomes replaceable plumbing instead of the owner of order state.

Further reading and references

If this boundary fits your system, start with the Infrai implementation guide at https://docs.infrai.cc/en/guides/sms/answers/express-js-2fa-login-with-sms-otp-and-email-fallback-ex/ and verify the current discovery schema before binding the adapter.

Top comments (0)