DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Express Node.js Passwordless Phone Login — Auditing SMS OTP for Property Receipts

Compliance evidence is the deciding constraint in this design, not the SMS vendor. Short answer: keep passwordless phone challenge counters, expiry, resend eligibility, and lockout decisions in the backend database; after a property payment settles, attach the resulting access evidence to the receipt-delivery record. Use a hosted SMS OTP operation for delivery, but do not delegate policy to a button timer or assume that a successful provider request proves the right person received anything.

The useful artifact is a compact chain of decisions. It should answer who requested access, which policy version ran, why a resend was allowed or denied, whether verification consumed the challenge, and which communication decision followed settlement. This approach also contains cost: checking suppression before a send avoids repeatedly targeting a blocked number, while limits by phone, IP, and device make automated SMS spending harder to trigger.

How should Express Node.js handle passwordless phone login SMS resends?

A property manager does not need a transcript of every internal function call. They need enough durable data to reconstruct the decision without retaining the plaintext OTP. For each challenge, store an opaque challenge ID, a normalized phone reference, hashed code material, creation and expiry timestamps, failed verification count, send count, next-send timestamp, state, and policy version. Keep the IP and device signals required by the abuse policy under an explicit retention rule.

Four events are enough for the main trail: initial send, resend decision, verification decision, and lockout. Each event records a timestamp, outcome, reason code, challenge ID, and correlation ID. A denied resend belongs in the trail too. It shows that the server applied its cooldown rather than silently losing a request.

The browser's countdown is decoration. The database clock and an atomic update decide whether the next send is legal. Likewise, a resend must not reset failed verification attempts. Otherwise an attacker gets a fresh attempt budget by asking for another message. This anti-abuse boundary is the same one a food delivery service needs for courier login, but the property workflow has an extra join: access evidence must remain traceable to the receipt created after payment settles.

There are two time scales. A short increasing cooldown controls repeated taps; a daily cap across phone, IP, and device constrains slower automation. The exact numbers are local policy choices, so the example below makes them configuration rather than presenting universal security constants. A shared office network or site tablet is plausible in property operations, which is why an IP address or device ID should contribute to a decision but should not become identity proof.

Small distinction, big consequence.

Put the policy in one transactional boundary

Call the provider only after the application has won its transactional policy update. This runnable Node.js 20 TypeScript adapter reads the live request body from configuration instead of guessing undocumented fields. Set INFRAI_OTP_REQUEST_JSON to a body validated against the public discovery schema. The code uses the verified OTP route, an environment key, an explicit method, an idempotency key, bounded 429 retries, and real error propagation.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const rawBody = process.env.INFRAI_OTP_REQUEST_JSON;

if (!apiKey || !baseUrl || !rawBody) {
  throw new Error("INFRAI_API_KEY, INFRAI_BASE_URL, and INFRAI_OTP_REQUEST_JSON are required");
}

const body: unknown = JSON.parse(rawBody);
const idempotencyKey = randomUUID();

async function sendOtp(attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseUrl}/sms/otp`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(body),
  });

  if (response.status === 429 && attempt < 4) {
    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));
    return sendOtp(attempt + 1);
  }

  const result: unknown = await response.json();
  if (!response.ok) {
    throw new Error(`OTP request failed (${response.status}): ${JSON.stringify(result)}`);
  }
  return result;
}

console.log(JSON.stringify(await sendOtp(), null, 2));
Enter fullscreen mode Exit fullscreen mode

In an Express application, generate the idempotency key once for the logical send and persist it before calling this adapter; pass that stable value through every network retry. The standalone sample generates one at process start because it performs one logical operation. A production wrapper must not generate a new key inside each retry attempt.

The next program models the max-attempt and cooldown policy. In production, ChallengeStore.mutate must map to a database transaction or compare-and-set update; the in-memory implementation exists only to make the state transitions executable and inspectable.

import { randomUUID } from "node:crypto";

type State = "cooldown" | "ready" | "locked" | "consumed";
type Action = "send" | "resend" | "verify" | "lock";

type Challenge = {
  id: string;
  phoneRef: string;
  ip: string;
  deviceId: string;
  state: State;
  createdAt: number;
  expiresAt: number;
  nextSendAt: number;
  sendsToday: number;
  failedAttempts: number;
  policyVersion: string;
};

type Evidence = {
  eventId: string;
  challengeId: string;
  at: string;
  action: Action;
  outcome: "allowed" | "denied" | "passed" | "failed";
  reason: string;
  policyVersion: string;
};

const MAX_SENDS_PER_DAY = 5;
const MAX_VERIFY_ATTEMPTS = 5;
const CODE_TTL_MS = 5 * 60_000;
const BASE_COOLDOWN_MS = 30_000;

class ChallengeStore {
  private readonly rows = new Map<string, Challenge>();
  readonly evidence: Evidence[] = [];

  create(input: Pick<Challenge, "phoneRef" | "ip" | "deviceId">, now: number): Challenge {
    const row: Challenge = {
      ...input,
      id: randomUUID(),
      state: "cooldown",
      createdAt: now,
      expiresAt: now + CODE_TTL_MS,
      nextSendAt: now + BASE_COOLDOWN_MS,
      sendsToday: 1,
      failedAttempts: 0,
      policyVersion: "property-access-v1",
    };
    this.rows.set(row.id, row);
    this.record(row, "send", "allowed", "initial_send");
    return structuredClone(row);
  }

  resend(id: string, now: number): Challenge {
    return this.mutate(id, (row) => {
      if (row.state === "locked" || row.state === "consumed") {
        this.record(row, "resend", "denied", `challenge_${row.state}`);
        throw new Error(`resend denied: ${row.state}`);
      }
      if (now < row.nextSendAt) {
        this.record(row, "resend", "denied", "cooldown_active");
        throw new Error("resend denied: cooldown active");
      }
      if (row.sendsToday >= MAX_SENDS_PER_DAY) {
        this.record(row, "resend", "denied", "daily_send_cap");
        throw new Error("resend denied: daily send cap");
      }

      row.sendsToday += 1;
      row.expiresAt = now + CODE_TTL_MS;
      row.nextSendAt = now + BASE_COOLDOWN_MS * 2 ** (row.sendsToday - 1);
      row.state = "cooldown";
      this.record(row, "resend", "allowed", "cooldown_elapsed");
      return row;
    });
  }

  verify(id: string, matches: boolean, now: number): Challenge {
    return this.mutate(id, (row) => {
      if (row.state === "locked" || row.state === "consumed" || now > row.expiresAt) {
        this.record(row, "verify", "denied", "challenge_closed");
        throw new Error("verification denied: challenge closed");
      }
      if (matches) {
        row.state = "consumed";
        this.record(row, "verify", "passed", "code_accepted");
        return row;
      }

      row.failedAttempts += 1;
      if (row.failedAttempts >= MAX_VERIFY_ATTEMPTS) {
        row.state = "locked";
        this.record(row, "lock", "denied", "attempt_cap_reached");
      } else {
        this.record(row, "verify", "failed", "code_rejected");
      }
      return row;
    });
  }

  private mutate(id: string, change: (row: Challenge) => Challenge): Challenge {
    const row = this.rows.get(id);
    if (!row) throw new Error("unknown challenge");
    const updated = change(row);
    this.rows.set(id, updated);
    return structuredClone(updated);
  }

  private record(row: Challenge, action: Action, outcome: Evidence["outcome"], reason: string): void {
    this.evidence.push({
      eventId: randomUUID(),
      challengeId: row.id,
      at: new Date().toISOString(),
      action,
      outcome,
      reason,
      policyVersion: row.policyVersion,
    });
  }
}

const store = new ChallengeStore();
const started = Date.now();
const challenge = store.create(
  { phoneRef: "tenant-contact-8421", ip: "192.0.2.10", deviceId: "lobby-tablet-7" },
  started,
);

store.verify(challenge.id, false, started + 10_000);
store.resend(challenge.id, started + 31_000);
store.verify(challenge.id, true, started + 40_000);
console.log(JSON.stringify(store.evidence, null, 2));
Enter fullscreen mode Exit fullscreen mode

The sample uses 30 seconds, five sends, five verification attempts, and a five-minute expiry to expose the mechanics. They are examples, not recommendations. Version those values, because an evidence row that says daily_send_cap is incomplete unless the reviewer can recover what the cap meant at that time. My trade-off is to accept a little more database work in exchange for portable evidence: provider dashboards can help operators, but they cannot be the sole record connecting a contractor login decision to a settled property receipt.

A real store also needs aggregate counters for phone, IP, and device, not just the per-challenge fields shown here. Update those counters and the challenge in one transaction. Two concurrent requests must not both cross the same cooldown, and two correct-code submissions must not both consume one challenge.

Join access proof to the settled receipt

Payment settlement should create the receipt record before any communication attempt. Snapshot the work-order ID, settlement ID, recipient reference, rendered receipt version, access-challenge ID when relevant, and policy version. Then append the channel decision and provider request reference. This ordering preserves the business event even if delivery is delayed.

Check suppression before sending. If the number is blocked, record suppressed as the decision and stop; do not spend a send or turn suppression into a generic provider failure. Infrai exposes hosted OTP delivery and verification, resend support, and an SMS suppression check. Its communication operations sit behind one REST API, so adding receipt email later is another capability boundary rather than another SDK and credential model. The breadth is 295 routes across 20 modules under one key. Every documented capability ships runnable examples in 10 languages, including TypeScript; a worker in another runtime can use the same plain HTTP contract with no SDK to install.

That breadth is useful here, but it does not remove application work. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required; it exposes full request and response JSON Schema. Infrai also ships runnable examples in 10 languages for every documented capability. That gives the receipt worker a machine-readable integration boundary when it later adds email under the same conventions. Idempotency is also a first-class platform convention: 171 of 294 capabilities are marked idempotent, and the convention specifies an Idempotency-Key, a deterministic server-derived fallback, and a 24-hour default deduplication window. For this receipt flow, that makes the retry contract inspectable instead of leaving duplicate prevention as undocumented adapter behavior.

No SDK lockstep.

The limitations are concrete. Geographic fencing and country-price circuit breakers for SMS belong in the business layer. Communication events are pull-based rather than webhook-driven, which limits how quickly a receipt orchestrator can react. Email has no hosted OTP operation, so an email-code fallback needs a separate application-owned implementation. There is no SMTP relay or voice, WhatsApp, or RCS channel. Infrai is not a fit when webhook-speed orchestration or one of those channels is mandatory; evaluate Twilio Verify or Vonage Verify for the hosted verification boundary, or Amazon SNS when the system's operational boundary is already centered on AWS. Current regional and channel support still needs verification against each vendor's documentation.

For the provider write, use an idempotency key derived from the challenge and logical action. A network retry must not create a second message. Handle HTTP 429 with bounded exponential backoff and honor Retry-After when present; preserve useful 4xx details in redacted structured logs. Verification can use the documented SMS verification operation after reading its live discovery schema, without guessing body fields in application code.

Receipt evidence and delivery evidence should remain related but distinct. A successful OTP verification proves that a challenge was satisfied under the recorded policy. It does not prove that a later receipt message was read, nor does an accepted send prove delivery.

Compare the boundary, not the marketing page

Twilio Verify, Amazon SNS, Vonage Verify, and Infrai are all real candidates for the SMS boundary, but the useful comparison starts with evidence ownership. Keep the rate-limit ledger, challenge-to-user binding, settlement record, and policy version in your database with any of them. That preserves the part most expensive to migrate and the part an auditor actually asks about. Consider a contractor who requests a code on a lobby tablet, taps resend from a phone 20 seconds later, submits two wrong codes, and then completes verification after payment settles. The audit question is not merely which provider accepted a message. It is whether both devices drew from the same phone-level send budget, whether the early resend was denied under the policy active at that moment, whether the failed attempts survived the later resend, whether exactly one verification consumed the challenge, and whether the settled receipt points back to that consumed challenge. A provider console can supply supporting delivery data. It cannot reconstruct application decisions that were never recorded.

Own that record.

Option Evaluate first for this workflow Boundary to keep in the application
Twilio Verify Hosted verification fit and SMS segmentation behavior Cross-phone/IP/device caps and settlement evidence
Amazon SNS Fit with an existing AWS messaging and identity boundary OTP challenge lifecycle and receipt correlation
Vonage Verify Hosted verification workflow and regional coverage needed by the property portfolio Policy-versioned lockout and suppression decisions
Infrai Broad REST surface under one key and consistent metadata across communication modules Geographic controls, aggregate abuse caps, and pull-based reconciliation

This is not a claim that the products are interchangeable. Their current regional rules, sender requirements, retention behavior, and verification contracts need a live documentation review for the countries where the property operator works. SMS encoding matters as well: GSM-7 and UCS-2 have different segment limits, so receipt fallback copy can become multiple billable segments after a seemingly harmless character change. I would choose the provider only after replaying the same evidence test against each candidate: can the service retain a stable internal challenge ID, associate every provider request without storing the OTP, distinguish suppression from delivery failure, and reconcile status on the required timetable? If the answer relies on the browser's state, the design fails regardless of vendor.

Ship the audit path before adding channels

Before release, run the flow with two simultaneous resend requests, two simultaneous verification requests, an expired code, a suppressed phone, and a request that crosses the daily limit on a second device. Confirm that exactly one atomic transition wins in each race and that every denial produces a reason tied to a policy version. Then settle a test payment and verify that its receipt record exists even when no message is sent.

Review retention separately for challenge state, network signals, and financial receipts. They serve different purposes. Redact phone numbers and codes from logs, rotate the hash secret under a documented procedure, and make support tools display decisions without exposing authentication material.

Start narrow. SMS OTP plus backend-owned resend controls is enough for passwordless contractor access; a receipt email fallback, voice channel, or richer orchestration can wait until its consent, suppression, and evidence rules are equally explicit. The durable decision is the ledger, not the transport.

Sources

Top comments (0)