DEV Community

HoratioFox1281
HoratioFox1281

Posted on

Node.js SMS OTP Login Polling for Creator Payouts (with 2FA Evidence)

TL;DR: For a marketplace payout challenge, treat SMS as a restricted authentication channel and delivery status as evidence about transport, not proof that the seller received or entered the code. Keep provider credentials and OTP verification on the server. Let the browser poll your own short-lived challenge resource, while a signed delivery callback updates an append-only event history. Require a stronger factor when phone-number takeover risk is unacceptable.

Pick this pattern When it fits Evidence it produces Main limit
Server polling only Callbacks are unavailable Request, response, and timeout records Repeated upstream reads
Signed callbacks only No live browser update is needed Transport transitions The UI can be stale
Callback plus first-party polling A payout UI needs feedback and an auditable record Correlated send, callback, and client-read events More state-machine work
Non-SMS factor Recovery or high-risk changes Factor-specific verification evidence A separate enrollment flow

The hybrid is the useful default. The browser never polls a messaging provider. It asks the application whether challenge ch_7f31 is pending, while the server reconciles delivery events independently. Small boundary. Clear evidence.

That separation is deliberate.

How should Node.js poll SMS OTP status during 2FA login?

A transport status answers one narrow question: what did the messaging network report about an attempted send? It does not establish possession of the phone, successful authentication, or authorization to release money. Only a correct OTP submitted within policy can advance authentication. The payout decision remains a separate authorization event.

Delivery is asynchronous. A send can be accepted before its terminal outcome exists. Callbacks may be duplicated or arrive out of order, and a handset can display a message after the application has expired the challenge. Model those facts instead of compressing them into one verified boolean.

Consider one concrete ordering. At second 0, the application creates the challenge. At second 1, the gateway accepts the attempt, so the page may say that the code was sent. The callback reports delivered at second 9, but the seller submits the correct OTP at second 7. Authentication succeeds because the submitted code and policy permit it, not because the later transport event grants permission. Reverse those last two events and the rule stays the same. If the challenge expires first, a delayed delivered event remains valuable transport evidence but cannot revive the code. This is why one status field cannot carry the whole story.

Use three linked records: Challenge for authentication, MessageAttempt for transport, and AuditEvent for each observed transition. Diagram in words: browser -> challenge API -> OTP service -> message gateway; gateway -> signed callback -> event store; browser -> challenge API -> projection. The gateway never points directly to the browser.

Store a keyed digest of the OTP, not the OTP. Avoid message bodies in logs. Mask phone numbers in operational views, while keeping the normalized destination under the access and retention controls that apply to authentication data. NIST also says verifiers should consider risk indicators such as SIM change and number porting for PSTN out-of-band authentication.

Pick callbacks plus first-party polling

There is a real trade-off here.

Polling only can fit when the upstream system offers no callback and traffic is modest. Set a deadline, back off, and stop at a terminal state. Otherwise every open tab becomes an unbounded status reader. It is also hard to distinguish “nothing changed” from “the lookup failed” unless those outcomes have separate metrics.

Callbacks only fit back-end notification jobs. During login, a callback can be delayed, retried, or delivered while the page is disconnected.

The hybrid gives each mechanism one job. The callback writes transport observations. First-party polling reads application state. The client receives a projection, never provider credentials or raw callback data.

This pattern is not suitable when policy requires phishing-resistant authentication, when the user has no stable phone access, or when the organization cannot operate callback verification and event reconciliation. Pick an enrolled phishing-resistant factor for the first case. For low-volume internal workflows without callbacks, bounded server polling may be the smaller and more supportable design. The hybrid spends engineering effort to gain fresher UI state and clearer evidence; that cost is wasted when neither matters.

For a seller receiving a new-order notice, delivery failure can trigger another channel under policy. For creator payout verification, that same failure must not silently bypass the factor. The jobs may share transport infrastructure; their authorization consequences differ.

Implement a server-owned state machine

The gateway below is generic. The application owns identifiers and policy; its adapter owns transport translation.

type DeliveryState = "queued" | "sent" | "delivered" | "undelivered" | "unknown";
type ChallengeState = "pending" | "verified" | "expired" | "locked";

interface MessageGateway {
  sendOtp(input: {
    destinationE164: string;
    code: string;
    correlationId: string;
  }): Promise<{ externalMessageId: string; acceptedAt: string }>;
}

interface ChallengeView {
  id: string;
  challengeState: ChallengeState;
  deliveryState: DeliveryState;
  expiresAt: string;
  retryAfterMs: number;
}
Enter fullscreen mode Exit fullscreen mode

Generate the code with a cryptographically secure random source. Bind its keyed digest to the challenge ID and purpose, expire it, cap attempts, and make successful verification a one-way transition. A resend creates a new message attempt rather than overwriting evidence for the first attempt.

The polling handler returns only what the page needs. Cache-Control: no-store prevents a cache from serving old authentication state. The authenticated session must be authorized to see this challenge.

interface ChallengeStore {
  getViewForActor(id: string, actorId: string): Promise<ChallengeView | null>;
}

export async function getChallengeStatus(
  request: Request,
  store: ChallengeStore,
  actorId: string
): Promise<Response> {
  const id = new URL(request.url).searchParams.get("challengeId");
  if (!id) return new Response(null, { status: 400 });

  const view = await store.getViewForActor(id, actorId);
  if (!view) return new Response(null, { status: 404 });

  return Response.json(view, {
    headers: {
      "Cache-Control": "no-store",
      "Retry-After": String(Math.ceil(view.retryAfterMs / 1000))
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The browser should use bounded exponential backoff with jitter. Stop on every terminal challenge state, not just success.

The sample starts at 1 second and caps at 8 seconds. Those values illustrate a bound; they are not a measured universal optimum.

export async function waitForChallenge(
  id: string,
  deadlineMs: number,
  signal: AbortSignal
): Promise<ChallengeView> {
  let delayMs = 1000;
  while (Date.now() < deadlineMs) {
    const response = await fetch(
      `/api/auth/challenge-status?challengeId=${encodeURIComponent(id)}`,
      { cache: "no-store", signal }
    );
    if (!response.ok) throw new Error(`Status read failed: ${response.status}`);

    const view = (await response.json()) as ChallengeView;
    if (["verified", "expired", "locked"].includes(view.challengeState)) return view;

    const jitterMs = Math.floor(Math.random() * 250);
    await new Promise<void>((resolve) => setTimeout(resolve, delayMs + jitterMs));
    delayMs = Math.min(delayMs * 2, 8000);
  }
  throw new Error("Challenge status deadline exceeded");
}
Enter fullscreen mode Exit fullscreen mode

Verify callback signatures over the exact bytes and request metadata specified by the gateway, reject stale requests, and claim a stable event ID in a transaction. Apply a transition only under the adapter's documented ordering rule. An already-claimed event should receive a successful response so retries do not multiply audit rows. There is no universal signature algorithm.

Keep separate clocks. Authentication policy decides when a code expires, even if a late callback later reports delivery. The honest sequence is “delivered late; challenge already expired.”

Make the evidence queryable before production

Logs tell one challenge's story. Metrics show whether behavior changed. Alerts ask for action.

For each transition, emit a structured event with challenge_id, an internal message_attempt_id, event type, source, observed timestamp, transport timestamp when supplied, and policy version. Never use a phone number as a metric label. Identifiers belong in access-controlled logs or traces.

A compact metric set is enough: challenge creation, verification outcomes, terminal delivery outcomes, callback age, polling reads, and state-transition conflicts. Measure accepted-to-terminal and created-to-verified latency as distributions. Alert on sustained symptoms tied to a response playbook. One undelivered message is a user-level event, not a page.

Test the awkward orderings: duplicate callbacks; sent after delivered; delivery after expiry; two open tabs; verification during a status read; and a valid callback whose event ID was already processed. These cases teach more than another happy-path screenshot.

A reviewer should reconstruct this sequence without narrative: challenge created under policy payout-stepup-v3; attempt accepted; transport transitions observed; OTP accepted or rejected; challenge became terminal; payout authorization consumed the verified challenge. Retention and access must follow the organization's policy and applicable requirements when US and EU phone data share infrastructure. A log shape alone does not create compliance.

Know the limits

NIST SP 800-63B treats PSTN out-of-band authentication as restricted, requires an alternative authenticator, and says out-of-band authentication is not phishing-resistant. Excellent reconciliation does not raise that ceiling.

SMS can still fit a defined assurance and recovery policy. For a phone-number change, account recovery, or unusually risky payout, route to an enrolled stronger factor or manual review instead of interpreting delivered as identity evidence.

The operational promise is narrow: this design shows what the application requested, what transport reported, what the user submitted, and which policy made the authorization decision. It cannot prove that a particular human saw the message. Good evidence preserves that boundary.

References

Top comments (0)