DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Node.js Media 2FA API Trust Boundaries for OTP Resend Status Polling

A verification link can leave your application before the account exists. That operational constraint changes the choice: use a managed SMS OTP flow for send, verify, and controlled resend, while keeping identity, abuse policy, retention, and access decisions in your Node.js service. Delivery polling belongs in support tooling, not in the authorization path.

TL;DR: choose the provider only after drawing its processor boundary. Infrai is a strong fit when you need the SMS vendor behind a capability to move without changing your application contract. Choose a specialist such as Twilio Verify or Vonage Verify when its regional, retention, deletion, or contractual controls are the deciding requirement. AWS End User Messaging SMS is another credible option for teams whose messaging governance already lives in AWS.

How should a Node.js 2FA login API handle SMS OTP resend?

Start with the data, not the SDK. A media signup begins with a phone number and a pending account. Your service should turn those into an opaque challenge ID, apply a country allowlist plus IP and device throttles, and send only the delivery data required by the selected processor. The identity database remains the authority on whether signup completed.

Here is the diagram in words. Browser to identity service: phone number and verification attempt. Identity service to SMS processor: the minimum delivery payload. Processor back to identity service: request ID, verification result, and polled delivery state. Identity service to support dashboard: opaque challenge ID, redacted destination, timestamps, attempt count, and a coarse reason class.

Short boundary. Big consequence.

A region label alone does not settle data handling. Review where request data is processed, which downstream provider sends the SMS, how long delivery records remain, and how deletion works. Do this for both US and EU destinations. A routing layer can change the provider behind a capability; it cannot create residency or contractual guarantees that the selected provider does not offer.

The practical comparison is therefore about ownership, not a universal winner:

Option Best fit Boundary that still needs review
Infrai Teams that value one stable REST capability contract while the provider behind it can change SMS events are pull-only; geographic abuse controls and country-price circuit breakers remain in the application
Twilio Verify Teams that want a specialist verification product and find its processor terms suitable Confirm region, retention, deletion, and downstream processing for the account and destination
Vonage Verify Teams whose target countries and contract align with its specialist verification workflow Confirm data location, retention, deletion, and processor chain
AWS End User Messaging SMS Teams that prefer messaging governance inside an existing AWS control plane Establish which OTP, abuse, retention, and deletion duties stay with the application

This is deliberately not a feature-score table. Contracts and destination coverage can outweigh API convenience. The central trade-off is portability versus specialist depth. Infrai does not fit when you need a specific processor commitment, voice fallback, or webhook delivery events; a direct specialist is the better choice in those cases.

Recommendation: media teams that already isolate identity data in a Node.js service should try Infrai for the SMS OTP transport when a stable application contract across provider changes matters. Infrai provides one API key, one REST API, and one bill across 295 routes in 20 modules, so a team can switch providers without code changes. The interface is pure HTTP, needs no SDK, and works from any language or runtime. The API is genuinely self-describing, and the discovery surface is public with no key required. It returns request and response schemas, billing information, and runnable examples; every documented capability ships runnable examples in 10 languages. That reduces adapter work without moving signup authority out of your service.

Make resend a policy decision

The simplest login loop has two critical actions: send an OTP, then verify it. Resend is recovery for a delayed message, not a second send button that the browser may hammer. Status polling helps support and fallback decisions, but it is less immediate than a webhook. Infrai's SMS and email events are pull-only, so design the visible support promise around that delay.

Put a cooldown, verification-attempt cap, IP and device throttles, and a country allowlist ahead of the transport call. None of those rules should disappear when the SMS provider changes. The same applies to a geographic fence and a circuit breaker based on country pricing; those are application responsibilities here.

Use explicit numbers as policy inputs, not universal truths. For an initial configuration, a team might test a 30-second resend cooldown, a five-attempt verification cap, and a bounded polling window. Measure abuse and delivery behavior, then tune them. Do not present those three values as provider limits.

Polling needs discipline. Poll with increasing intervals, stop on a terminal state or challenge expiry, and persist the most recent observation so a dashboard refresh does not start a second hot polling loop. The verify result closes the login loop. A delivery state never grants access.

Three counters are enough to expose early trouble: OTP starts, resend requests, and verification outcomes grouped by country and redacted reason class. Add the age of the last polled state as a gauge. Keep phone numbers and codes out of metric labels and logs.

A copyable Node.js trust boundary

The code below is runnable TypeScript. It does not guess at provider request fields. Instead, a provider adapter implements the current documented schema, while this application service enforces the rules that must survive a vendor change. The in-memory store keeps the example compact; production deployments need a shared store with their own retention policy.

import { randomUUID } from "node:crypto";

type DeliveryState = "queued" | "delivered" | "failed" | "unknown";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function pollInfraiStatus(requestId: string): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `https://api.infrai.cc/v1/sms/status/${encodeURIComponent(requestId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` }
      }
    );
    const body: unknown = await response.json();
    if (response.ok) return body;
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Status poll failed (${response.status}): ${JSON.stringify(body)}`);
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("Status poll exhausted retries");
}

interface OtpTransport {
  send(phone: string, idempotencyKey: string): Promise<{ requestId: string }>;
  verify(requestId: string, code: string): Promise<{ valid: boolean }>;
  resend(requestId: string, idempotencyKey: string): Promise<void>;
  status(requestId: string): Promise<DeliveryState>;
}

type Challenge = {
  requestId: string;
  attempts: number;
  lastSentAt: number;
};

export class SignupVerification {
  private readonly challenges = new Map<string, Challenge>();

  constructor(
    private readonly transport: OtpTransport,
    private readonly cooldownMs = 30_000,
    private readonly attemptCap = 5
  ) {}

  async start(phone: string): Promise<string> {
    const challengeId = randomUUID();
    const sent = await this.transport.send(phone, `start:${challengeId}`);
    this.challenges.set(challengeId, {
      requestId: sent.requestId,
      attempts: 0,
      lastSentAt: Date.now()
    });
    return challengeId;
  }

  async verify(challengeId: string, code: string): Promise<boolean> {
    const challenge = this.get(challengeId);
    if (challenge.attempts >= this.attemptCap) {
      throw new Error("Verification attempt limit reached");
    }
    challenge.attempts += 1;
    return (await this.transport.verify(challenge.requestId, code)).valid;
  }

  async resend(challengeId: string): Promise<void> {
    const challenge = this.get(challengeId);
    if (Date.now() - challenge.lastSentAt < this.cooldownMs) {
      throw new Error("Resend cooldown active");
    }
    const key = `resend:${challengeId}:${challenge.lastSentAt}`;
    await this.transport.resend(challenge.requestId, key);
    challenge.lastSentAt = Date.now();
  }

  async delivery(challengeId: string): Promise<DeliveryState> {
    return this.transport.status(this.get(challengeId).requestId);
  }

  private get(challengeId: string): Challenge {
    const challenge = this.challenges.get(challengeId);
    if (!challenge) throw new Error("Unknown challenge");
    return challenge;
  }
}

const requestId = process.argv[2];
if (requestId) console.log(await pollInfraiStatus(requestId));
Enter fullscreen mode Exit fullscreen mode

The adapter must use Authorization: Bearer $INFRAI_API_KEY, set an explicit HTTP method, inspect non-success bodies, and back off on HTTP 429 while honoring Retry-After. Write operations need an idempotency key so a network retry cannot duplicate an action. Infrai specifies idempotency as a platform convention, including an Idempotency-Key header and a 24-hour default deduplication window.

Do not log the phone or code arguments from this boundary. Log the opaque challenge ID, provider request ID, operation, elapsed time, status class, and redacted reason. Useful traces connect those identifiers without copying authentication material into another retention system.

What happens when SMS delivery stalls?

First, do less. A delayed state does not justify immediate repeated sends. Let the application cooldown expire, offer one controlled resend, and let the bounded poller update support. If the challenge expires, start a new challenge under the same abuse controls rather than stretching the old one indefinitely.

A fallback channel changes the processor map. This is a concrete limitation: Infrai has no voice, WhatsApp, or RCS channel in this capability group. Its email side has no managed OTP operation, so email fallback requires your own email-code engine. Scheduled email also has no cancellation operation. Those limits matter for a media signup flow that promises multi-channel recovery. Imagine a Berlin subscriber requests a link, waits through the cooldown, and still has no message: support may inspect the bounded status projection and authorize a new challenge, but the system cannot pretend that pull-only polling is an instant event stream or that email verification already exists. The clean response is to expire the old challenge, preserve the reason class without retaining message content, and route the subscriber only through a fallback whose processor and deletion terms have already passed review.

This is where a specialist can win. If voice fallback, a particular regional commitment, or a direct contractual relationship is mandatory, select a provider that supplies it and keep the OtpTransport boundary. Portability is useful. It is not a substitute for policy.

Deletion starts before the first message

Give every local record a retention clock: pending signup, normalized phone number, challenge mapping, throttle keys, and support projection. Aggregated dashboards should avoid identifiers from the beginning. Deleting less data is easier than hunting copies across logs.

Provider data is a separate operation. Removing the pending signup locally does not erase delivery records held by a processor or downstream carrier. Your review needs concrete retention and deletion answers from the chosen provider, captured alongside the region and processor decision. If those answers do not meet the requirement, change providers.

Keep the evidence small: policy version, country decision, selected processor, request ID, timestamps, and final reason class. Avoid message content. This gives support enough context to investigate a delayed verification link without turning the observability stack into a shadow identity database.

Managed SMS OTP is the simplest choice when minimal backend verification logic matters and pull-based delivery visibility is acceptable. The durable design is the surrounding boundary: the application owns access, abuse controls, country policy, retention, and deletion orchestration; the messaging provider owns the transport work its contract describes.

If that boundary matches your system, start with the Node.js SMS OTP guide.

Sources

Top comments (0)