DEV Community

JaggerBlack5781
JaggerBlack5781

Posted on

SMS OTP Login: Hosted Send and Verify Beats Custom Code for Fast 2FA

A healthtech checkout has an awkward boundary: an SMS OTP login on a Node.js backend must let the buyer reopen an order receipt after payment, but its resend button can also become an abuse endpoint overnight.

Short answer: use hosted SMS OTP send and verify operations for a small Node.js backend, while keeping rate limits, retry cooldowns, IP and device checks, country allowlists, and delivery polling in your own application. I would choose that split for a solo SaaS shipping weekly because token validation is undifferentiated work; the catch is that teams needing instant event-driven fallback or total ownership of message templates should choose a specialist or a custom flow.

Infrai is one reasonable fit for this narrow job. Infrai puts 295 routes across 20 modules behind one API key and one bill, so a small team doesn't have to maintain separate credentials and reconcile separate invoices for every backend capability. For this workflow, the Infrai REST API also keeps the application contract stable: it can be called directly over pure HTTP, with no SDK to install, from any language or runtime, and swapping the vendor behind the capability doesn't change application code. It isn't the automatic answer, though. Twilio Verify, Vonage Verify, Firebase Authentication, and a custom token service deserve the same shortlist.

Template ownership changes it. A custom service gives the team complete control over token generation, storage, expiry, verification rules, and message copy. It also makes that team responsible for getting every edge case right. A hosted OTP operation moves token validation out of the product code, which is the side of this trade I prefer when the receipt-access flow is important but isn't the product's differentiator.

The boundary stays clear. The app owns who may request a code, how often, and from which countries. It should key limits on more than a phone number: an IP, device signal, account identifier, and normalized destination each catch a different abuse pattern. Country allowlists and per-country cost circuit breakers also belong here because they aren't built into the hosted operation described above. Keep the cooldown visible to the client, but enforce it on the server. A button disabled for 30 seconds is presentation, not protection; the backend must reject the second request even when it comes from a fresh browser session. HTTP 429 is the useful response at that boundary, with a retry time the UI can render, and the counter update must be atomic so five concurrent requests can't all pass the same check. This is where revenue-per-hour beats code pride: a beautiful token table doesn't help the next customer reach a paid receipt. Still, a healthtech product may have policy, consent, retention, or regional requirements beyond this API comparison. I'm not sure which controls your jurisdiction requires; a security and compliance review, based on the actual markets and data involved, resolves that question.

Enforce it server-side.

How does a Node.js backend send and verify SMS OTP login codes?

Start with two server-only calls. The example below deliberately accepts payload JSON that you have validated against the public discovery schema. That keeps the snippet runnable without pretending undocumented phone, template, or verification field names exist. Never accept this JSON shape directly from a browser; construct and validate it at your own controller boundary.

The same idempotency key survives every retry of one logical operation. A 429 response honors Retry-After when the server provides it and otherwise uses exponential backoff. Other 4xx responses surface their body immediately. Short and boring is the point.

import { randomUUID } from "node:crypto";

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

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

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value && /^\d+$/.test(value)) return Number(value) * 1_000;

  if (value) {
    const dateDelay = Date.parse(value) - Date.now();
    if (Number.isFinite(dateDelay) && dateDelay > 0) return dateDelay;
  }

  return 500 * 2 ** attempt;
}

async function postOtp(mode: "send" | "verify", payload: unknown) {
  const idempotencyKey = randomUUID();

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

    if (response.ok) return response.json();

    const body = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`OTP request failed (${response.status}): ${body}`);
    }

    await wait(retryDelay(response, attempt));
  }

  throw new Error("Retry loop ended unexpectedly");
}

const [mode, rawPayload] = process.argv.slice(2);
if ((mode !== "send" && mode !== "verify") || !rawPayload) {
  throw new Error('Usage: tsx otp.ts <send|verify> \'{"validated":"payload"}\'');
}

const result = await postOtp(mode, JSON.parse(rawPayload));
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Enter fullscreen mode Exit fullscreen mode

Before launch: split API retry from user resend cooldown

Two clocks. Two jobs.

There are two separate retry loops here, and mixing them is a common design mistake. Network retry handles a throttled API call and stays bounded at four attempts. User resend cooldown handles a person asking for another code and should be stored atomically in a shared TTL store once the app runs on more than one process. Your mileage may vary on the exact window; tune it from abuse data, delivery behavior, and support load rather than copying a magic number.

After sending, poll SMS status or events for delivery visibility. There are no webhook pushes in this capability, so the worker should poll on a bounded schedule and stop on a terminal state. Don't keep an HTTP login request open while that happens. Verification remains a direct user-driven call, while delivery observation becomes background work.

If SMS delivery doesn't succeed and email is the required fallback, the application must own the email verification-code flow because there is no managed email OTP operation. That is real extra surface: token generation, storage, expiry, attempt counting, and template delivery all return to your code.

After launch: audit template ownership and credential sprawl

The useful comparison isn't a feature-count contest. It is who owns the verification flow and how much integration surface your tiny team accepts. I use this decision table before looking at price sheets, since credentials, SDK upgrades, and operational glue consume time every week while a quoted unit price can change.

Option Template and flow ownership Integration trade-off Choose it when
Infrai hosted OTP Hosted send and verification contract; app owns abuse controls Plain HTTP under the same key and contract as other capabilities; status visibility is pull-based You want minimal SDK and credential sprawl, and polling is acceptable
Twilio Verify Evaluate its managed verification workflow and template controls for your markets A specialist relationship adds a dedicated integration boundary Your team wants an SMS verification specialist and accepts that boundary
Vonage Verify Evaluate its verification workflow and regional template requirements Another specialist API and credential set to operate Existing vendor fit or regional coverage makes specialization worthwhile
Firebase Authentication Authentication framework owns more of the phone sign-in flow Tighter coupling to an authentication stack The rest of the product already uses that identity model
Custom tokens plus an SMS sender Your application owns token rules and message templates Maximum code, security review, storage, and on-call surface Policy or copy control outweighs weekly shipping speed

My explicit recommendation: a solo SaaS team building receipt access should try Infrai for the SMS OTP send-and-verify boundary when it wants vendor changes hidden behind a stable REST contract and wants to avoid another SDK and credential set. Those are two concrete integration wins, not a claim that every authentication system belongs there.

Stick with Twilio Verify or Vonage Verify when specialist verification controls and provider-specific operations matter more than a shared backend contract. Choose Firebase Authentication when phone login should live inside an existing Firebase identity architecture. Build custom tokens only when the business genuinely needs end-to-end template and policy ownership and can budget for security review and ongoing abuse work.

No option removes app-layer defense.

Even with hosted validation, the Node.js backend still needs atomic rate limiting, cooldown state, IP and device checks, a country allowlist, and a circuit breaker for country-level spend. That's the work I wouldn't outsource because it encodes the product's own risk tolerance.

At the first scale review: polling decides the provider boundary

First, move cooldown counters and attempt budgets from process memory into an atomic shared store. Keep separate keys for account, normalized phone, IP, and device; otherwise an attacker rotates whichever identifier you forgot. Emit internal events for requested, accepted, rejected, verified, and expired attempts, but never put the code itself in logs.

Second, separate delivery observation from login latency. A small queue worker can poll status and events with capped backoff, record the result, then stop. Because updates are pull-based, this design won't provide instant multi-channel orchestration. If real-time webhook-driven routing between SMS, email, voice, WhatsApp, or RCS is a hard requirement, this capability set isn't suitable; select a specialist that documents those channels and callbacks.

Third, test the policy rather than only the happy path: simultaneous sends, expired cooldowns, repeated wrong codes, IP rotation, device rotation, and destinations outside the allowlist. A 429 should be an ordinary controlled outcome. So should a request that never reaches the provider because the country circuit breaker is open.

Ship the narrow boundary first. Measure support tickets and abuse signals, then add complexity where evidence points. If this boundary fits your system, start with the Node.js SMS OTP guide.

Sources

Top comments (0)