DEV Community

SaxonFletcher2366
SaxonFletcher2366

Posted on

Node.js SMS OTP API: Rate-Limited Seller Login for New Orders

Short answer: keep the new-order SMS free of sensitive order details, and put a short-lived OTP challenge in front of the seller view. The least complex useful design is one Node.js service that stores a hashed challenge, enforces resend and verification limits atomically, and emits low-cardinality events for every decision. The SMS says an order is waiting; the authenticated page reveals the order.

Start with the controls, not the SMS API:

Control Pick this when What to observe
One cooldown per challenge A seller taps resend several times otp_resend decisions by result
Attempt ceiling A caller keeps guessing one code otp_verify failures and lockouts
Account and network windows Requests rotate across challenges rejected sends by policy key
Generic responses Phone-number discovery is a concern latency parity between known and unknown accounts
Separate reminder delivery The notification may be delayed or lost reminder state apart from authentication state

This split matters. A delivered message is not proof that the recipient authenticated, and a correct OTP is not proof that the original reminder arrived. Model those as separate facts.

How should a Node.js SMS OTP login API limit code retries?

Pick the per-challenge cooldown first when accidental repeat sends are the main problem. A resend request inside the cooldown should return the same outward response as an accepted request, while the server records cooldown_active. Do not extend the expiry every time somebody taps resend. That can turn a short challenge into a rolling credential.

In this example, the cooldown is 30 seconds, expiry is five minutes, and lockout follows five failed checks. Those values are policy, not universal constants. Start with them only to make behavior testable, then tune from observed completion and abuse patterns. The explicit trade-off is friction: tighter windows reduce an attacker's room to guess, while also giving a distracted seller less time to move from a lock-screen alert to the order page.

Add an attempt ceiling when verification is exposed to untrusted clients. NIST's digital identity guidance requires rate limiting for failed authentication attempts and treats use of the public switched telephone network as a restricted authenticator. That is a useful design boundary: SMS can be one constrained authentication step, but it should not quietly become the permanent root of trust for a seller account.

Use account and network windows for distributed abuse. Neither dimension is sufficient alone. An account-only limit lets an attacker spray many accounts; a network-only limit can punish many legitimate users behind one carrier gateway or office connection. The trade-off is deliberate: combine both signals, keep the thresholds configurable, and send ambiguous cases to additional review instead of pretending one number works everywhere.

The downside is extra state and a harder support conversation when a legitimate seller reaches a limit. The benefit is that rotating challenge IDs or source addresses no longer resets every control. Record which policy made the decision so support can investigate without seeing the code itself.

Keep generic responses from day one. 202 Accepted can mean "the request was evaluated" rather than "this phone number exists and a message was sent." The internal event retains the real decision. The public response does not become an account-enumeration side channel.

Picture five boxes in a row: idle -> issued -> verified. From issued, a clock can move the challenge to expired; failed guesses can move it to locked. Resend does not create an unlimited side door. It either reuses the active challenge under policy or replaces it while invalidating the previous one.

Make each transition atomic. This is the sharp edge.

Two Node.js workers can receive verification requests within the same millisecond. If both read attempts = 4, both may increment to 5 and both may believe they are allowed. Use a transactional row update or an atomic compare-and-set in the store. The same rule applies to verifiedAt: only one transition from unverified to verified should succeed.

The record needs little data: a random challenge identifier, seller identifier, code hash, expiry, next-send time, failed-attempt count, status, and timestamps. Avoid putting a raw phone number, OTP, message body, or order contents in logs. Those fields do not help a dashboard answer "are challenges being rejected correctly?" and they raise the cost of every log export.

Implement the policy in Node.js

The example below keeps transport and storage behind interfaces. That makes the policy testable without choosing a messaging provider or database library. The store methods must be atomic in a real implementation.

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

type Challenge = {
  id: string;
  sellerId: string;
  codeHash: Buffer;
  expiresAt: number;
  nextSendAt: number;
  failedAttempts: number;
  status: "issued" | "verified" | "expired" | "locked";
};

type Decision =
  | "sent"
  | "cooldown_active"
  | "expired"
  | "invalid"
  | "locked"
  | "verified";

interface ChallengeStore {
  create(challenge: Challenge): Promise<void>;
  get(id: string): Promise<Challenge | null>;
  claimResend(
    id: string,
    now: number,
    nextSendAt: number,
    codeHash: Buffer,
  ): Promise<boolean>;
  recordFailure(id: string, maxAttempts: number): Promise<"invalid" | "locked">;
  markVerified(id: string): Promise<boolean>;
}

interface SmsTransport {
  send(phone: string, body: string): Promise<{ messageId: string }>;
}

interface EventSink {
  emit(name: "otp_send" | "otp_resend" | "otp_verify", fields: {
    challengeId: string;
    sellerId: string;
    result: Decision;
  }): void;
}

const hashCode = (challengeId: string, code: string): Buffer =>
  createHash("sha256").update(`${challengeId}:${code}`).digest();

export class SellerOtpService {
  private readonly ttlMs = 5 * 60_000;
  private readonly cooldownMs = 30_000;
  private readonly maxAttempts = 5;

  constructor(
    private readonly store: ChallengeStore,
    private readonly sms: SmsTransport,
    private readonly events: EventSink,
  ) {}

  async issue(sellerId: string, phone: string): Promise<string> {
    const now = Date.now();
    const id = randomUUID();
    const code = randomInt(0, 1_000_000).toString().padStart(6, "0");

    await this.store.create({
      id,
      sellerId,
      codeHash: hashCode(id, code),
      expiresAt: now + this.ttlMs,
      nextSendAt: now + this.cooldownMs,
      failedAttempts: 0,
      status: "issued",
    });
    await this.sms.send(phone, `Your seller code is ${code}.`);
    this.events.emit("otp_send", { challengeId: id, sellerId, result: "sent" });
    return id;
  }

  async resend(id: string, sellerId: string, phone: string): Promise<void> {
    const now = Date.now();
    const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
    const claimed = await this.store.claimResend(
      id,
      now,
      now + this.cooldownMs,
      hashCode(id, code),
    );
    if (!claimed) {
      this.events.emit("otp_resend", {
        challengeId: id,
        sellerId,
        result: "cooldown_active",
      });
      return;
    }

    await this.sms.send(phone, `Your seller code is ${code}.`);
    this.events.emit("otp_resend", { challengeId: id, sellerId, result: "sent" });
  }

  async verify(id: string, sellerId: string, suppliedCode: string): Promise<boolean> {
    const challenge = await this.store.get(id);
    if (!challenge || challenge.sellerId !== sellerId) {
      this.events.emit("otp_verify", { challengeId: id, sellerId, result: "invalid" });
      return false;
    }

    if (challenge.status !== "issued" || Date.now() >= challenge.expiresAt) {
      this.events.emit("otp_verify", { challengeId: id, sellerId, result: "expired" });
      return false;
    }

    const suppliedHash = hashCode(id, suppliedCode);
    if (!timingSafeEqual(challenge.codeHash, suppliedHash)) {
      const result = await this.store.recordFailure(id, this.maxAttempts);
      this.events.emit("otp_verify", { challengeId: id, sellerId, result });
      return false;
    }

    const verified = await this.store.markVerified(id);
    this.events.emit("otp_verify", {
      challengeId: id,
      sellerId,
      result: verified ? "verified" : "invalid",
    });
    return verified;
  }
}
Enter fullscreen mode Exit fullscreen mode

There is an intentional omission: the example does not pretend that an in-memory counter is a rate limiter. Cross-worker limits belong in a shared store with atomic expiry. Put the account and network checks before issue, then return the same public response for accepted and rejected requests.

Also notice that the message contains no order number, buyer name, address, item, or appointment-like detail. The alert creates urgency without leaking the transaction onto a lock screen.

Observe decisions, not secrets

Three event names are enough to start. Their bounded result values support rates and alerts without turning seller IDs into metric labels. Keep identifiers in access-controlled logs or traces; aggregate metrics on operation and result.

A useful dashboard follows the state machine. Chart accepted sends, cooldown rejections, failed verification, lockouts, expirations, and successful verification. Then compare two ratios: verification success after an accepted send, and expiry without verification. A sudden rise in cooldown decisions can mean a retrying client. A rise in invalid attempts across many accounts can indicate guessing. A rise in expirations with stable request volume points toward a delivery or user-flow problem, but it does not prove either one by itself.

Alert on sustained changes from an established baseline, not on a single failed code. One typo is ordinary. Thousands of distributed guesses are not. Keep the alert dimensions coarse enough that the monitoring system itself cannot reveal which seller received an order.

One send. One outcome.

For traces, carry a generated challenge ID from the HTTP handler through the policy check and send job. Do not carry the OTP. This gives an operator a diagram in time: request accepted, policy decided, send attempted, verification evaluated. Each span answers one question.

Test the boundaries before deployment

Use a fake clock and table-driven tests. Check one millisecond before expiry and exactly at expiry. Fire two resend calls concurrently and assert that only one claim succeeds. Fire two correct verification calls and assert that only one marks the challenge verified. Then try five wrong codes followed by the correct code; the configured ceiling must win.

Deployment deserves the same care. Ship event production first, confirm the dashboard can distinguish invalid, expired, and locked, and only then attach paging alerts. If challenge records outlive their usefulness, delete them under an explicit retention policy. Logs and traces need retention limits too.

Do one more review with the team that owns account recovery. SMS authentication is a restricted mechanism in NIST guidance, so recovery cannot casually weaken the controls around it. A support action that resets attempts should be auditable and should not expose the old code.

Limits

This design cannot make SMS resistant to every phone-network or account-takeover risk. It can bound guessing, suppress resend storms, avoid leaking order data, and show operators which state transition is failing.

That is the practical finish line: a seller gets a terse alert, the order remains behind an authenticated view, and the system can explain every send and verification decision without recording the secret.

Further reading

Top comments (0)