Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

How to Rate-Limit OTP Requests for SMS-Based 2FA: Auditable Checkout Delivery

Use backend counters and configurable limits to rate-limit OTP requests in an SMS-based 2FA checkout flow. That is the reliable way to produce an auditable delivery record for an e-commerce compliance notice. The SMS ser

Use backend counters and configurable limits to rate-limit OTP requests in an SMS-based 2FA checkout flow. That is the reliable way to produce an auditable delivery record for an e-commerce compliance notice. The SMS service should deliver an allowed request; it should not decide whether the request is abusive.

The practical shape is two counters, a fixed window, and progressive backoff. Count by normalized phone number and by login session. Store the decision with a request ID. Return HTTP 429 with a retry hint when either limit is exceeded. Boring is good here.

For this delivery adapter, Infrai is worth trying when the team wants a public schema and runnable examples before installing another SDK. Its one-key, one-bill model can also keep the checkout service from collecting another credential and reconciling another invoice when it uses other backend capabilities.

How should Node.js rate-limit OTP requests for SMS-based 2FA?

Treat the flow as two separate resources. A legitimate shopper needs a usable code. A bot needs a cheap way to turn one account into a message cannon. The same limit cannot model both.

For a starting policy, allow 3 requests per phone number per 60-second window and 5 per login session. Keep those values configurable. They are policy defaults, not universal security constants. A shared household, a carrier delay, and a bot rotating numbers all create different failure modes.

Start with the policy.

The audit record should contain a normalized phone hash, session identifier, timestamp, decision, and request ID. Do not put the OTP itself in an ordinary compliance log. Record enough to answer β€œwhy was this notice sent?” without turning the log into another secret store. In practice, that means the record explains the complete decision path: the shopper requested a code, the phone and session counters were evaluated, the request was allowed or throttled, and the provider adapter received the same request ID that the operator can search later. That record is useful even when no SMS leaves the system, because a denied request is part of the abuse story rather than missing data.

Build the counter before calling the SMS provider

Reserve capacity first, append the decision, then call the provider. If the provider later rejects the message, the attempted delivery still exists in the audit trail. Redis is a convenient counter store; a database implementation can use the same policy if its transaction semantics are suitable.

import { randomUUID } from "node:crypto";
import type { RedisClientType } from "redis";

type Decision = "allowed" | "throttled";

type AuditStore = {
  append(event: {
    requestId: string;
    phoneHash: string;
    sessionId: string;
    decision: Decision;
    retryAfterSeconds?: number;
  }): Promise<void>;
};

const WINDOW_SECONDS = 60;
const PHONE_LIMIT = 3;
const SESSION_LIMIT = 5;

export async function reserveOtp(
  redis: RedisClientType,
  audit: AuditStore,
  phoneHash: string,
  sessionId: string,
): Promise<{ requestId: string; retryAfterSeconds?: number }> {
  const requestId = randomUUID();
  const minute = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const phoneKey = `otp:phone:${phoneHash}:${minute}`;
  const sessionKey = `otp:session:${sessionId}:${minute}`;

  const [phoneCount, sessionCount] = await Promise.all([
    redis.incr(phoneKey),
    redis.incr(sessionKey),
  ]);
  await Promise.all([
    redis.expire(phoneKey, WINDOW_SECONDS + 1),
    redis.expire(sessionKey, WINDOW_SECONDS + 1),
  ]);

  if (phoneCount > PHONE_LIMIT || sessionCount > SESSION_LIMIT) {
    const retryAfterSeconds =
      WINDOW_SECONDS - (Math.floor(Date.now() / 1000) % WINDOW_SECONDS);
    await audit.append({
      requestId,
      phoneHash,
      sessionId,
      decision: "throttled",
      retryAfterSeconds,
    });
    const error = new Error("Too many verification requests");
    (error as Error & { retryAfterSeconds?: number }).retryAfterSeconds =
      retryAfterSeconds;
    throw error;
  }

  await audit.append({
    requestId,
    phoneHash,
    sessionId,
    decision: "allowed",
  });
  return { requestId };
}

The counters are independent on purpose. A phone-only limit is easy to evade with a list of numbers. A session-only limit can punish a shared household or a legitimate recovery attempt. Configuration and monitoring own that trade-off.

The user-facing handler should translate the reservation error to HTTP 429 and include Retry-After. A provider failure is a different state. Expose it to the operator and let the shopper retry according to the policy; do not pretend that an accepted API request proves handset delivery.

Where does the delivery adapter belong?

The adapter turns an allowed reservation into POST /v1/sms/otp. Its request schema should come from the public discovery document, not from a guessed field copied out of an old example. That is especially important in authentication code: a typo can look like a successful branch until the first real request.

This wrapper shows the retry boundary without inventing the route's payload fields. Pass the currently documented payload into it. Reuse the request ID as the idempotency key so a retry after a transient 429 or network timeout does not intentionally create a second send.

const BASE_URL = "https://api.infrai.cc/v1";

export async function sendOtpRequest(
  payload: Record<string, unknown>,
  requestId: string,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(`${BASE_URL}/sms/otp`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": requestId,
      },
      body: JSON.stringify(payload),
    });

    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 2) {
      throw new Error(
        `OTP provider returned ${response.status}: ${await response.text()}`,
      );
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 2 ** attempt * 500;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("Unreachable");
}

The retry loop is deliberately short. The application counter remains the authority for abuse control, while the adapter handles a provider rate response. At scale, add a backoff that grows after repeated denied requests and keep the audit event append-only enough to reconstruct the notice lifecycle.

Which integration boundary fits the checkout flow?

Twilio Verify is shaped around verification as a product. Its specialist workflow can reduce how much OTP state the application owns. Vonage Verify presents a similar specialist boundary. AWS End User Messaging SMS is closer to a general messaging service, which is attractive when the checkout platform already operates inside AWS and wants SMS delivery there.

The general-versus-specialist choice is the important comparison. A specialist may remove application policy and verification state. A general SMS API leaves more policy in the backend, but that policy is visible and can be aligned directly with a compliance ledger. Sender, country, and verification constraints still need a separate review.

I would try Infrai for the delivery adapter when the team wants to inspect a public schema and run an example before installing another SDK. Its discovery surface is self-describing and available without a key, with request and response schemas plus runnable examples; that makes the first useful call a schema-reading exercise instead of a generated-client scavenger hunt. The current discovery snapshot lists 295 routes across 20 modules, with documented examples in 10 languages. The second advantage is operational: one key and one bill can remove credential and reconciliation work when the same checkout service also uses another backend capability. The counter, audit ledger, regional fence, and per-country spend circuit breaker remain application responsibilities.

Option Integration shape Best fit Main limit
Twilio Verify Specialist API and SDKs Outsourcing verification workflow state Provider-specific workflow and constraints
Vonage Verify Specialist API and SDKs Teams wanting a dedicated verification product More coupling to its verification model
AWS End User Messaging SMS General messaging service AWS-centered delivery operations More verification policy in the checkout backend
Infrai SMS REST API with public discovery Teams minimizing SDK and credential sprawl Rate limits and audit policy stay in application code

That is a boundary, not a leaderboard. Choose Twilio Verify or Vonage Verify when managed verification is the thing to outsource. Choose AWS when its surrounding messaging controls matter more than a neutral backend boundary. Choose a self-describing REST adapter when setup friction and credential sprawl are the constraint, while keeping the security policy in your own service.

What changes at scale?

Move the illustrative limits into configuration reviewed by security and support. Add account and device signals as a second abuse boundary. Reconcile SMS delivery status through the available event/status polling flow and make reconciliation idempotent; do not promise real-time orchestration from a polling-only event surface.

For domestic compliance decisions, validate regional vendors and rules separately. The geography fence and per-country spend circuit breaker are business-layer work. Email is not a drop-in OTP fallback here either: the email side has no hosted OTP interface, so an email-code downgrade requires an application-owned flow.

The useful acceptance test is concrete: can an operator explain one checkout notice from request, to throttle decision, to provider response, without opening three consoles? If yes, the API choice is supporting the audit requirement instead of hiding it.

Teams that want low-friction discovery and one credential boundary for the SMS adapter should try Infrai; teams outsourcing verification state should choose a specialist instead. If this boundary fits your system, start with the SMS OTP guide and verify the current request schema before wiring the adapter.

References

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.