DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

Node.js Two-Factor Authentication — SMS Polling and Backup Email Code Delivery

A payment-settled receipt flow cannot wait indefinitely for an SMS status. Keep one OTP challenge in the application, send it by SMS first, observe that attempt for a bounded period, and claim one backup email send if SMS fails or remains unresolved. Polling informs the fallback decision. It never authenticates the user.

TL;DR: Correlate both delivery attempts with one challengeId, store only a hash of the code, and accept success only when the server verifies user input. Keep receipt preparation independent from login delivery so a slow carrier cannot delay business work that is already valid.

For a customer-support receipt portal, that gives two clean lanes. Payment settlement prepares the receipt. A returning customer completes OTP login before retrieving it. Support can then distinguish “receipt not prepared” from “customer could not sign in.”

Keep those lanes separate.

Replace Callback Tangles with One Observable Challenge

The fragile version starts email from whichever SMS callback or timer happens first. Duplicate sends become likely, logs have no stable join key, and a provider's sent response gets mistaken for user receipt.

Use four records instead: challenge, attempt, observation, and verification. The challenge owns the hashed code and expiry. Each channel send creates an attempt. Polling appends observations. Verification compares submitted input on the server.

Picture it left to right: challenge -> SMS attempt -> bounded observation -> optional email attempt -> verification. Provider states are mapped into a small application vocabulary: pending, delivered, or failed. Preserve the raw state for diagnosis, but do not spread transport-specific strings through domain code.

This model has a real limitation. Polling adds status traffic and worker state, while delivery receipts remain incomplete evidence. For low-risk flows where a second factor is optional, a user-controlled “send by email” action may require less integration. For higher-assurance authentication, email and SMS may both be unsuitable because they depend on channels outside the authenticator's control.

A Copyable TypeScript State Machine

The adapter boundary below is generic. The important invariant is the atomic fallback claim, not a particular transport API.

import { createHash, randomInt, randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";

type State = "pending" | "delivered" | "failed";

interface Sms {
  send(to: string, body: string): Promise<{ id: string }>;
  status(id: string): Promise<State>;
}
interface Email {
  send(to: string, subject: string, body: string): Promise<{ id: string }>;
}
interface Store {
  create(row: { id: string; hash: string; expiresAt: number }): Promise<void>;
  claimEmailFallback(challengeId: string): Promise<boolean>;
}
interface Events {
  write(event: Record<string, string | number>): void;
}

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

export async function startLogin(deps: {
  phone: string; email: string; sms: Sms; mail: Email;
  store: Store; events: Events;
}) {
  const challengeId = randomUUID();
  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  const expiresAt = Date.now() + 5 * 60_000;
  await deps.store.create({ id: challengeId, hash: digest(challengeId, code), expiresAt });

  const smsAttempt = await deps.sms.send(
    deps.phone,
    `Your receipt portal code is ${code}. It expires in 5 minutes.`,
  );
  deps.events.write({ name: "otp.attempt", challengeId, attemptId: smsAttempt.id, channel: "sms" });

  void observe({ ...deps, challengeId, code, messageId: smsAttempt.id });
  return { challengeId, expiresAt };
}

async function observe(deps: {
  challengeId: string; code: string; messageId: string; email: string;
  sms: Sms; mail: Email; store: Store; events: Events;
}) {
  const deadline = Date.now() + 30_000;
  let state: State = "pending";

  while (state === "pending" && Date.now() < deadline) {
    await delay(2_000);
    state = await deps.sms.status(deps.messageId);
    deps.events.write({ name: "otp.observed", challengeId: deps.challengeId, state });
  }
  if (state === "delivered") return;
  if (!(await deps.store.claimEmailFallback(deps.challengeId))) return;

  const attempt = await deps.mail.send(
    deps.email,
    "Your receipt portal sign-in code",
    `Your sign-in code is ${deps.code}. It expires in 5 minutes.`,
  );
  deps.events.write({ name: "otp.attempt", challengeId: deps.challengeId, attemptId: attempt.id, channel: "email" });
}
Enter fullscreen mode Exit fullscreen mode

The 2-second interval, 30-second window, and 5-minute lifetime are sample policy values, not standards. Configure them from measured delivery behavior, abuse risk, and the login experience. Run observe in a durable worker in production; an HTTP process can restart after returning the challenge ID.

claimEmailFallback must be atomic. A compare-and-set update or unique database constraint lets two workers observe the same deadline without sending two emails. Both channels use one code, so a retry must not create another valid secret. Enforce attempt limits, expiry, single use, and invalidation after success. In this sample, a worker can make at most 15 status checks at 2-second intervals before the 30-second deadline; changing the interval changes status traffic as well as reaction time, which is a concrete integration trade-off rather than a free reliability improvement.

What Should Delivery Telemetry Prove?

Log identifiers, not destinations or codes. Useful fields include challengeId, attemptId, normalized state, channel, elapsed time, and a reason category. Phone numbers, email addresses, bodies, and OTPs do not belong in routine telemetry.

Signal It can reveal It cannot prove
Terminal failure rate Transport or integration failures User possession
Time to terminal state Observation latency Reading time
Email fallback rate Missing positive SMS evidence Email preference
Verification success Correct code entry Which receipt caused it
Expired challenges An incomplete flow Why the user stopped

Reconstruct one challenge with logs. Use metrics for population behavior. Alert only on sustained, actionable conditions such as a growing worker queue or a sharp change in normalized failures.

Email authentication has another boundary. DKIM lets a verifier validate a signature over selected headers and the body for a signing domain; it does not show that a person read the message. Likewise, an SMS delivered observation should stop automatic fallback, but it should not authorize receipt access.

When Should Two-Factor Authentication Poll SMS Before Backup Email?

Should pending trigger email immediately? Usually no. Dual-send increases exposure of the same secret and obscures channel attribution. A bounded wait gives the primary attempt time to produce evidence.

A terminal failure can escalate immediately. An unresolved attempt may escalate at the deadline. A delivered observation stops automatic escalation, while the UI can still offer user-initiated recovery after an appropriate delay. If status lookup fails, classify it as unknown, retry within the budget, then apply the deadline policy.

Rate-limit challenge creation and verification separately. Return generic responses so login cannot enumerate registered phone numbers or addresses. NIST describes out-of-band authentication as restricted, and OWASP recommends short-lived, single-use OTPs with strict attempt limits. This fallback is a security trade-off, not only a messaging feature.

Testing does not need a carrier.

Use a scripted adapter with pending, delivered, pending, failed, repeated pending, and thrown status errors. Drive it with a fake clock. Assert zero or one email claim, one stable challenge ID, no second code, and no secret in events.

Then race two workers against the atomic claim. Exactly one must win. This catches the duplicate-email failure that a happy-path mock misses.

Finally, test the receipt boundary: settlement prepares the receipt once; verification only authorizes retrieval. Deploy behind counters for pending duration, fallback rate, completion, worker errors, and queue age. The aim is one explainable authentication decision built from imperfect transport evidence.

References

Top comments (0)