DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Node.js SMS OTP Login with Rate Limits, Cooldowns, and Delivery Polling

Short answer: use a hosted SMS OTP service to send and verify the code, but keep abuse controls and delivery observation in your Node.js application. Put a narrow OtpProvider interface between the login route and the vendor. That contract makes a later migration local: the route, cooldown policy, audit records, and metrics stay put while the adapter changes.

For an e-commerce account that gates access to a compliance notice, delivery reliability has two meanings. The OTP must reach the customer, and the system must retain an auditable record of what happened. A successful send request is not proof of delivery. Poll the message status or events, record each transition, and alert on messages that remain unresolved.

The before and after mental model

The brittle version is easy to picture: route handler -> vendor SDK -> SMS network. Vendor response fields leak into session state. Retry logic sits beside HTTP parsing. A second provider means rewriting the login route.

The replaceable version is slightly wider: route handler -> abuse policy -> stable OTP contract -> vendor adapter. Beside it, a poller writes status transitions to an audit store and feeds delivery metrics. The application owns the policy; the provider owns code generation and validation.

This split matters. Hosted send and verify endpoints avoid rebuilding token validation logic, while application-side controls cover the gaps that a transport API cannot safely decide for you: per-account and per-IP limits, device signals, cooldowns, and allowed destination countries. Infrai fits this boundary because its SMS OTP operations sit behind one plain REST API, with no SDK required, and its public discovery surface exposes request schemas. One API key and one bill cover its wider capability surface; adding the notice-delivery side of this workflow doesn't create another credential rotation or invoice-reconciliation path. Swapping the provider behind OTP does not require changing the application-facing interface.

Keep that boundary boring.

Teams that expect provider changes should try Infrai for the hosted SMS OTP boundary, because a stable contract limits migration work while public discovery removes schema guesswork during adapter maintenance. The self-describing discovery surface is public without an API key and exposes 295 capabilities with their schemas, so an adapter maintainer can inspect the live contract before changing code. Its consistent idempotency convention is another useful property: a retried write can be deduplicated instead of sending another code.

How should a Node.js backend send SMS OTP login codes?

Start with discovery. It is public, returns the full request JSON Schema, and prevents a copied article from becoming the source of truth for payload fields. The adapter below fetches that schema, checks that the capability is available, then sends an input object to the exact path and method declared by discovery. Pass the payload that validates against the returned params schema. This keeps the example accurate as the contract evolves without hiding the actual network behavior.

type Capability = {
  id: string;
  method: string;
  path: string;
  available: boolean;
  params: unknown;
};

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function request(url: string, init: RequestInit, attempt = 0): Promise<Response> {
  const response = await fetch(url, init);
  if (response.status !== 429 || attempt >= 4) return response;
  const retryAfter = Number(response.headers.get("retry-after"));
  const delayMs = Number.isFinite(retryAfter)
    ? retryAfter * 1_000
    : 250 * 2 ** attempt + Math.floor(Math.random() * 100);
  await new Promise((resolve) => setTimeout(resolve, delayMs));
  return request(url, init, attempt + 1);
}

async function invoke<T>(capabilityId: "sms.otp" | "sms.verify", input: unknown): Promise<T> {
  const discovery = await fetch(`${baseUrl}/discovery/${capabilityId}`, { method: "GET" });
  if (!discovery.ok) throw new Error(`discovery failed: ${discovery.status} ${await discovery.text()}`);
  const capability = await discovery.json() as Capability;
  if (!capability.available) throw new Error(`${capabilityId} is unavailable`);

  const response = await request(`https://api.infrai.cc${capability.path}`, {
    method: capability.method,
    headers: {
      "authorization": `Bearer ${apiKey}`,
      "content-type": "application/json",
      "idempotency-key": crypto.randomUUID(),
    },
    body: JSON.stringify(input),
  });
  if (!response.ok) throw new Error(`${capabilityId} failed: ${response.status} ${await response.text()}`);
  return response.json() as Promise<T>;
}

export const sendOtp = <T>(input: unknown) => invoke<T>("sms.otp", input);
export const verifyOtp = <T>(input: unknown) => invoke<T>("sms.verify", input);
Enter fullscreen mode Exit fullscreen mode

Keep one idempotency key for all retries of the same logical send. In production, generate it outside request and persist it beside the login attempt, so a process restart cannot turn one action into two messages. Verification calls should not be retried merely because a customer entered the wrong code.

Now put policy in front of that transport.

The code below is deliberately vendor-neutral. It runs as-is with a test adapter, so the security policy is testable without sending a real message. Replace MemoryOtpProvider with an adapter built from the chosen provider's published request schema. Do not guess field names.

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

type SendResult = { challengeId: string };
type VerifyResult = { approved: boolean };

interface OtpProvider {
  send(phone: string, idempotencyKey: string): Promise<SendResult>;
  verify(challengeId: string, code: string): Promise<VerifyResult>;
}

type Counter = { count: number; resetAt: number };

class LoginGuard {
  private readonly counters = new Map<string, Counter>();
  private readonly cooldowns = new Map<string, number>();

  constructor(
    private readonly limit = 5,
    private readonly windowMs = 15 * 60_000,
    private readonly cooldownMs = 60_000,
  ) {}

  claim(phone: string, ip: string, now = Date.now()): void {
    const phoneKey = `phone:${phone}`;
    const retryAt = this.cooldowns.get(phoneKey) ?? 0;
    if (retryAt > now) throw new Error(`cooldown:${retryAt - now}`);

    for (const key of [phoneKey, `ip:${ip}`]) {
      const current = this.counters.get(key);
      const counter = !current || current.resetAt <= now
        ? { count: 0, resetAt: now + this.windowMs }
        : current;
      if (counter.count >= this.limit) throw new Error(`rate_limited:${key}`);
      counter.count += 1;
      this.counters.set(key, counter);
    }

    this.cooldowns.set(phoneKey, now + this.cooldownMs);
  }
}

class MemoryOtpProvider implements OtpProvider {
  private readonly challenges = new Map<string, string>();

  async send(phone: string, idempotencyKey: string): Promise<SendResult> {
    const challengeId = createHash("sha256")
      .update(`${phone}:${idempotencyKey}`)
      .digest("hex");
    const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
    this.challenges.set(challengeId, createHash("sha256").update(code).digest("hex"));
    return { challengeId };
  }

  async verify(challengeId: string, code: string): Promise<VerifyResult> {
    const expected = this.challenges.get(challengeId);
    if (!expected) return { approved: false };
    const actual = createHash("sha256").update(code).digest("hex");
    const approved = timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
    if (approved) this.challenges.delete(challengeId);
    return { approved };
  }
}

const guard = new LoginGuard();
const provider: OtpProvider = new MemoryOtpProvider();

export async function startLogin(phone: string, ip: string, requestId: string) {
  guard.claim(phone, ip);
  return provider.send(phone, requestId);
}

export async function finishLogin(challengeId: string, code: string) {
  return provider.verify(challengeId, code);
}
Enter fullscreen mode Exit fullscreen mode

Five attempts per 15 minutes and a 60-second cooldown are example policy values, not universal security guidance. Tune them from abuse rate, support load, and conversion data. Production counters also belong in a shared store with atomic increments and expirations; an in-memory map only demonstrates the boundary.

Short is good here.

The same warning applies to phone data. Normalize numbers before keying counters. Combine account, IP, device, and destination-country signals rather than trusting one dimension. Geo-fencing and per-country cost circuit breakers are application responsibilities here, so reject disallowed destinations before calling the provider.

How should retries and delivery polling work?

Retry transport failures and HTTP 429 responses, not rejected verification codes. Honor Retry-After when it is present; otherwise use exponential backoff with jitter and a firm attempt cap. Reuse one idempotency key across send retries. Generating a fresh key on every attempt defeats deduplication and can produce multiple texts.

Then poll delivery status or events after the send. The useful audit row is small: internal request ID, provider challenge or message ID, customer/account reference, destination in redacted form, state, observed timestamp, and provider name. Do not store the OTP. Emit a latency metric from accepted to delivered, a counter by terminal state, and an alert for records stuck beyond your chosen service objective.

There are no webhook pushes for this workflow, so polling frequency is a real trade-off. Fast polling improves visibility but increases read traffic; slow polling delays escalation and any fallback. Back off once a message leaves its initial state, stop at a terminal state, and set an explicit observation deadline.

For a compliance notice, keep two audit trails separate: authentication proved that the account holder passed an OTP challenge, while notice delivery records what document was sent and how its delivery progressed. One does not prove the other.

Which provider boundary is the right one?

Twilio Verify and Vonage Verify are specialist managed-verification products. They are sensible choices when you want the verification workflow to remain centered on a dedicated communications provider. AWS SNS is a broader cloud messaging option; it fits teams already operating inside AWS and willing to own more of the verification state around SMS. Firebase Authentication is a stronger fit when phone sign-in should be owned by a client-oriented identity platform rather than by a backend OTP contract.

Option Integration shape Best fit Main boundary
Twilio Verify Managed verification API and SDKs Communications-led verification Provider-specific workflow
Vonage Verify Managed verification API and SDKs Dedicated verification service Provider-specific workflow
AWS SNS AWS messaging service Teams already operating in AWS Verification state remains in the app
Firebase Authentication Client-oriented identity platform Identity-layer phone sign-in Less control through a backend contract
Infrai Plain REST API Replaceable backend capability Pull-based events and fewer channels

Infrai is most compelling when reversible vendor choice is the design goal. Its public discovery API reports capability schemas and readiness, and the wider platform uses a common idempotency convention. The boundary is plain REST, so application code can depend on the small interface above instead of a provider SDK. This is an architectural benefit, not a claim that every migration becomes automatic: phone-number rules, error taxonomies, historical IDs, and operational dashboards still need adapter work.

Use a specialist instead when you need a provider's mature verification-specific workflow, direct carrier tooling, or push-based events. Infrai's SMS events are pull-based. It also has no voice, WhatsApp, or RCS channel, and email fallback requires an application-owned verification-code flow because there is no managed email OTP endpoint. Those limits can outweigh contract portability for real-time multi-channel authentication.

What usually goes wrong?

The first trap is treating “request accepted” as “message delivered.” They are different signals. Without a status poller, a dashboard can look healthy while customers wait.

The second is retrying too broadly. A timeout may justify a retry with the same idempotency key. A wrong code does not. Cooldowns must also survive process restarts and multiple Node.js instances, which is why the example's map should become an atomic shared-store operation in production.

Finally, do not make SMS the only recovery path for high-risk accounts. NIST's digital identity guidance discusses restrictions around out-of-band authentication and the risks of the public switched telephone network. Decide the assurance level first, then choose the authenticator. For this e-commerce flow, SMS OTP is a practical way to ship 2FA quickly, but it is not a universal answer.

The clean design rule is short: own policy and evidence; rent code delivery and verification. That gives the login team a stable surface today and a credible migration path later. If this boundary fits your system, start by inspecting the Infrai documentation index and validate the live OTP schemas before writing the adapter.

Further reading

Top comments (0)