DEV Community

FrozenSigh2853916
FrozenSigh2853916

Posted on

Passwordless Phone Login in Express Node.js — SMS OTP Resend Flow

A food-delivery courier may request an OTP at a noisy pickup counter, miss the first message, and tap resend three times. That operational constraint changes the design: the Express Node.js backend, not the phone app or SMS provider, must own the resend cooldown and max verification-attempt budget.

TL;DR: Model send, resend, verify, and locked as explicit server states. Check number suppression before delivery, increase the cooldown after each resend, cap activity by phone, IP, and device each day, and store only the small amount of state needed to enforce those rules. Keep the delivery adapter replaceable. The stable contract belongs in your application; the SMS vendor sits behind it.

How should Express Node.js handle a passwordless phone login flow?

The fragile mental model is a straight line: app asks for code, provider sends code, app submits code. It leaves the important questions unanswered. Who decides that the second send is too soon? Which counter wins after a courier changes devices? What happens when the number is blocked?

Use a state machine instead. In words: ready goes to pending; pending can become verified, return to pending after an allowed resend, or become locked after too many failures. Expiry returns the challenge to ready. A suppressed number never enters delivery.

Short and explicit wins.

This is the anti-abuse boundary.

Keep the authoritative record on the server. It needs a challenge identifier, a hashed phone key, expiry time, next-resend time, resend count, failed-attempt count, status, and the IP and device keys used for abuse accounting. The client can display a countdown, but its clock is decoration. It must never set expiry, counters, or lockout state.

For a courier app, daily limits need three dimensions: phone, IP, and device. A phone-only cap is easy to spread across many numbers; an IP-only cap can punish everyone behind a shared carrier gateway. The combined decision costs more database work, but it gives the backend enough context to reject both concentrated and distributed abuse. Geographic fences and country-level spend circuit breakers also belong in this business layer rather than the delivery adapter.

A copyable Node.js state machine

This TypeScript example is deliberately about orchestration. OtpDelivery hides the provider-specific request schema, while Store must be backed by a database implementation that performs mutate atomically. The sample uses increasing cooldowns of 30, 60, and 120 seconds and locks after five failed checks. Those are example policy choices, not universal security constants.

import { createHash, randomUUID } from "node:crypto";

type Status = "pending" | "verified" | "locked";

type Challenge = {
  id: string;
  phoneKey: string;
  providerId: string;
  status: Status;
  expiresAt: number;
  resendAfter: number;
  resendCount: number;
  failedAttempts: number;
};

type Context = { phone: string; ip: string; deviceId: string };

interface Store {
  getActive(phoneKey: string): Promise<Challenge | null>;
  put(challenge: Challenge): Promise<void>;
  mutate(id: string, update: (current: Challenge) => Challenge): Promise<Challenge>;
  consumeDaily(key: string, limit: number): Promise<boolean>;
}

interface OtpDelivery {
  isSuppressed(phone: string): Promise<boolean>;
  send(phone: string, idempotencyKey: string): Promise<{ providerId: string }>;
  resend(providerId: string, idempotencyKey: string): Promise<void>;
  verify(providerId: string, code: string): Promise<boolean>;
}

type JsonObject = Record<string, unknown>;

class InfraiOtpDelivery implements OtpDelivery {
  constructor(
    private readonly apiKey: string,
    private readonly baseUrl: string,
    private readonly store: Store,
  ) {}

  private async post(path: string, body: JsonObject, key: string): Promise<JsonObject> {
    for (let attempt = 0; attempt < 4; attempt += 1) {
      const response = await fetch(`${this.baseUrl}${path}`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${this.apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": key,
        },
        body: JSON.stringify(body),
      });
      if (response.ok) return (await response.json()) as JsonObject;
      const errorBody = await response.text();
      if (response.status !== 429 || attempt === 3) {
        throw new Error(`delivery_failed:${response.status}:${errorBody}`);
      }
      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("delivery_failed");
  }

  async isSuppressed(phone: string): Promise<boolean> {
    return Boolean(await this.store.getActive(`suppressed:${digest(phone)}`));
  }

  async send(phone: string, idempotencyKey: string): Promise<{ providerId: string }> {
    const configuredPayload = JSON.parse(
      process.env.INFRAI_OTP_REQUEST_JSON ?? "{}",
    ) as JsonObject;
    const result = await this.post(
      "/v1/sms/otp",
      { ...configuredPayload, phone },
      idempotencyKey,
    );
    if (typeof result.id !== "string") throw new Error("missing_provider_id");
    return { providerId: result.id };
  }

  async resend(): Promise<void> {
    throw new Error("configure_provider_resend_adapter");
  }

  async verify(): Promise<boolean> {
    throw new Error("configure_provider_verify_adapter");
  }
}

const digest = (value: string) =>
  createHash("sha256").update(value).digest("hex");

const cooldownSeconds = (resendCount: number) =>
  [30, 60, 120][Math.min(resendCount, 2)];

export class CourierOtp {
  constructor(
    private readonly store: Store,
    private readonly delivery: OtpDelivery,
    private readonly now: () => number = Date.now,
  ) {}

  private async allow(ctx: Context): Promise<void> {
    const keys = [
      `phone:${digest(ctx.phone)}`,
      `ip:${digest(ctx.ip)}`,
      `device:${digest(ctx.deviceId)}`,
    ];
    const decisions = await Promise.all(
      keys.map((key) => this.store.consumeDaily(key, 20)),
    );
    if (decisions.some((allowed) => !allowed)) throw new Error("daily_limit");
  }

  async send(ctx: Context): Promise<{ challengeId: string; retryAt: number }> {
    await this.allow(ctx);
    if (await this.delivery.isSuppressed(ctx.phone)) throw new Error("suppressed");

    const phoneKey = digest(ctx.phone);
    const active = await this.store.getActive(phoneKey);
    if (active && active.expiresAt > this.now()) throw new Error("already_pending");

    const id = randomUUID();
    const sent = await this.delivery.send(ctx.phone, id);
    const challenge: Challenge = {
      id,
      phoneKey,
      providerId: sent.providerId,
      status: "pending",
      expiresAt: this.now() + 5 * 60_000,
      resendAfter: this.now() + 30_000,
      resendCount: 0,
      failedAttempts: 0,
    };
    await this.store.put(challenge);
    return { challengeId: id, retryAt: challenge.resendAfter };
  }

  async resend(id: string): Promise<{ retryAt: number }> {
    const current = await this.store.mutate(id, (item) => {
      if (item.status !== "pending") throw new Error("not_pending");
      if (item.expiresAt <= this.now()) throw new Error("expired");
      if (item.resendAfter > this.now()) throw new Error("cooldown");
      if (item.resendCount >= 3) return { ...item, status: "locked" };
      const resendCount = item.resendCount + 1;
      return {
        ...item,
        resendCount,
        resendAfter: this.now() + cooldownSeconds(resendCount) * 1_000,
      };
    });
    if (current.status === "locked") throw new Error("locked");
    await this.delivery.resend(current.providerId, `${id}:resend:${current.resendCount}`);
    return { retryAt: current.resendAfter };
  }

  async verify(id: string, code: string): Promise<boolean> {
    const current = await this.store.mutate(id, (item) => {
      if (item.status !== "pending") throw new Error(item.status);
      if (item.expiresAt <= this.now()) throw new Error("expired");
      return item;
    });
    const valid = await this.delivery.verify(current.providerId, code);
    await this.store.mutate(id, (item) => {
      if (valid) return { ...item, status: "verified" };
      const failedAttempts = item.failedAttempts + 1;
      return {
        ...item,
        failedAttempts,
        status: failedAttempts >= 5 ? "locked" : "pending",
      };
    });
    return valid;
  }
}

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) throw new Error("missing_infrai_configuration");
Enter fullscreen mode Exit fullscreen mode

The adapter reads the current, provider-validated request fields from INFRAI_OTP_REQUEST_JSON; check the public discovery schema before setting it. This avoids freezing a guessed request shape into the auth service. There is one subtle race here worth calling out: reserving a resend and delivering it are separate operations. The idempotency key makes a repeated delivery request represent the same logical resend. In production, pair the atomic mutation with an outbox worker so a process crash cannot leave an accepted resend permanently unsent. The worker should retry HTTP 429 responses with exponential backoff, honor Retry-After, and surface other non-success responses instead of treating them as delivery.

Log state transitions, never OTP values or raw phone numbers. Useful fields include the challenge ID, hashed phone key, old and new status, policy reason, provider request ID, latency, and attempt number. Metrics should count sends, suppression rejections, cooldown rejections, lockouts, expirations, and successful verifications. Alert on a sudden ratio change, not on every rejected tap.

Who should own the SMS template?

Template ownership decides how portable this adapter really is. An application-owned message gives product engineers one review path and makes a provider swap easier, but local telecom rules may require a registered provider template. A provider-owned template can satisfy that delivery workflow and let operations change approved copy without a deploy; it also puts template identifiers and lifecycle differences into the adapter.

Option Template ownership to plan for Best fit Boundary to accept
Twilio Decide between application copy and Twilio-managed messaging configuration after checking the target-country rules Teams already using Twilio's communications tooling Encoding changes segment count, so copy and variables need character-set review
Vonage Treat approved template identifiers as provider configuration Teams that want a dedicated communications vendor adapter Keep approval state and vendor identifiers out of the auth domain model
AWS End User Messaging SMS Treat templates, origination identity, and regional configuration as infrastructure configuration AWS-centered operations teams The auth service still owns cooldowns, counters, and lockout decisions
Infrai Keep the auth contract application-owned and isolate its hosted OTP and template operations in one adapter Teams that value replacing the vendor behind a capability without changing calling code Events are pull-based; geographic fences and country-price circuit breakers remain application work

Infrai's fit comes from one REST API and one key across backend capabilities; the adapter contract stays fixed when the vendor behind a capability changes. Its self-describing discovery surface also exposes the current schema without authentication. The trade-off is concrete: choose Twilio or Vonage instead when you need a communications specialist's broader channel tooling, and favor AWS End User Messaging SMS when regional AWS operations and ownership matter more than a cross-service API. Infrai is not a fit here if voice, WhatsApp, RCS, real-time webhook events, or provider-managed email OTP is required.

This is a design comparison, not a claim that the products expose identical controls. Read each provider's current country and template documentation before choosing. Also inspect the final encoded SMS: Twilio documents 160 GSM-7 characters for one segment, 70 UCS-2 characters, and smaller per-segment limits for concatenated messages. A single courier name or localized character can change segmentation, turn one message into several billable segments, and make a carefully sized template exceed the boundary only after real courier data is inserted.

My decision rule is plain: own the login states and the semantic message intent in Node.js; allow an adapter to translate that intent into an approved vendor template. Do not let a template ID become your authentication API.

What about bounces and blocked numbers?

SMS does not use email-style bounces, but the operational outcome is similar: a destination can become undeliverable or suppressed. Check suppression before the first send. Then ingest delivery state through the mechanism the provider actually offers and add confirmed invalid destinations to your suppression process. With a pull-only event source, polling adds detection delay, so the login endpoint must not promise real-time delivery truth.

This boundary matters for fallback. Infrai has no managed email OTP capability, so an email-code fallback would need its own implementation. It also does not provide voice, WhatsApp, or RCS channels. Do not draw those branches in the login diagram unless the selected provider and your application really implement them.

Keep suppression separate from temporary throttling. A suppressed phone should fail before delivery; a locked challenge should fail because this login session exhausted its policy. Mixing the two produces bad support tooling and misleading security metrics.

Can the client own the countdown and attempt count?

No. A courier can reinstall the app, alter local storage, change the device clock, or replay a request. The UI should receive a server-calculated retryAt and render it, while the database remains authoritative.

No exceptions.

The same rule applies to expiry. The server checks expiry before every resend and verification, and an atomic database update prevents two fast taps from spending the same allowance. Return stable reason codes such as cooldown, expired, locked, and suppressed; map them to friendly copy at the edge. That gives support and observability the same vocabulary without leaking provider errors into the product.

Ship the policy, then tune it

Start with explicit numbers, even when they will change: a five-minute challenge in the example, three resends with increasing cooldowns, five failed verifications, and a daily budget evaluated across three keys. Put those values in versioned server configuration. Review the resulting metrics by country, app version, and delivery vendor before adjusting them.

The key architectural choice lasts longer than any threshold. The courier app asks for login actions. The backend enforces policy. The adapter handles delivery and template translation. That split keeps abuse controls consistent and makes a vendor change an adapter project rather than an authentication rewrite.

Sources

Top comments (0)