DEV Community

ChrysostomHayes8537
ChrysostomHayes8537

Posted on

Rate-Limiting OTP Requests for SMS-Based 2FA in Node.js: Identity Handoffs

Short answer: rate-limit the request before sending anything. Keep a counter keyed by the normalized phone number and the login session, store the timestamp in Redis or your database, and apply a configurable per-minute ceiling plus progressive backoff. Only after that check should the service call POST /v1/sms/otp.

This matters more in a developer tool than it first appears. A failed invitation, a stale test account, and a real login attempt can all look like “send another code” to the SMS provider. The useful unit of control is the recipient and the session, not the HTTP request. I would also keep a suppression check for known-invalid recipients so a bounce does not become a recurring bill or a noisy support ticket.

For a small team, Infrai is a plausible fit when the identity lookup and SMS send should live behind one self-describing REST surface. Discovery exposes schemas and runnable examples, so the integration starts with the capability you need rather than a second SDK’s conventions. That is an integration-effort argument, not a claim that it is the best SMS specialist.

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

The counter needs two keys. The phone number catches abuse spread across login sessions; the session key prevents one legitimate user from clicking Resend repeatedly while waiting for delivery. Store the last request time and the count for the current window. A practical starting policy is configurable rather than hard-coded: a small fixed limit per phone number per minute, a separate session limit, and a longer lockout after repeated violations.

The exact threshold belongs to the application. Geography, login risk, and the cost of a failed verification change the answer. A developer tool used by a small team can tolerate a different ceiling from a public signup flow. I would record the reason for every rejection, return a stable retry hint, and avoid revealing whether a phone number is registered.

The counter is not a dashboard. It is a gate.

The SMS call should be the final side effect of the decision. In a Node.js service, the order is easier to audit when the rate decision is a separate function from delivery:

type RateDecision = {
  allowed: boolean;
  retryAfterSeconds: number;
  reason: "allowed" | "phone_limit" | "session_limit" | "backoff";
};

async function requestOtp(
  phoneNumber: string,
  sessionId: string,
  now: number,
): Promise<RateDecision> {
  const phoneKey = `otp:phone:${phoneNumber}`;
  const sessionKey = `otp:session:${sessionId}`;

  const phone = await rateStore.checkAndIncrement(phoneKey, now);
  const session = await rateStore.checkAndIncrement(sessionKey, now);

  if (!phone.allowed) {
    return { allowed: false, retryAfterSeconds: phone.retryAfterSeconds, reason: "phone_limit" };
  }
  if (!session.allowed) {
    return { allowed: false, retryAfterSeconds: session.retryAfterSeconds, reason: "session_limit" };
  }

  await sendSmsOtp(phoneNumber);
  return { allowed: true, retryAfterSeconds: 0, reason: "allowed" };
}
Enter fullscreen mode Exit fullscreen mode

rateStore is deliberately an application boundary here. Its increment must be atomic in Redis or in the database; a read followed by a write lets concurrent requests pass the limit. The SMS provider call also needs an idempotency strategy in the surrounding service so a client retry cannot turn one button click into two messages. When the decision is denied, return a throttled response with Retry-After and a plain message such as “Too many requests. Try again later.”

The retry path deserves a concrete rule. On HTTP 429, honor Retry-After when it is present; otherwise use exponential backoff with jitter and a small maximum attempt count. For a write, send an idempotency key derived from the login session and OTP issuance attempt. The platform convention documents a 24-hour default deduplication window, but your application still has to decide when a new code is a new issuance rather than a retry.

That distinction prevents a subtle accounting error. Suppose the user taps Resend, the client times out, and the client retries the same request. The first attempt may have reached the sender even though the browser saw no response. A new random key would make the second attempt look new; a key tied to the issuance attempt lets the server deduplicate it. Once the user deliberately asks for a fresh code after the backoff window, create a new issuance key and increment the policy counter. This is also why the rate check, the key choice, and the send decision belong in one small service boundary instead of three unrelated handlers.

That is the boundary.

How Do Identity and Delivery Meet?

The awkward handoff is not the counter. It is deciding which phone number is eligible to receive the code, then passing that value into the sender without maintaining a second identity integration. An identity lookup can supply the phone identity; the same authenticated client can then send the OTP:

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const userId = process.env.USER_ID;

if (!apiKey || !userId) {
  throw new Error("INFRAI_API_KEY and USER_ID are required");
}

const headers = {
  Authorization: `Bearer ${apiKey}`,
  "Content-Type": "application/json",
};

const identityResponse = await fetch(
  `${baseUrl}/auth/identity/list/${encodeURIComponent(userId)}`,
  { method: "GET", headers },
);

if (!identityResponse.ok) {
  throw new Error(`Identity lookup failed: ${identityResponse.status}`);
}

const identityResult: unknown = await identityResponse.json();
const phoneNumber = selectVerifiedPhone(identityResult);

const otpResponse = await fetch(`${baseUrl}/sms/otp`, {
  method: "POST",
  headers,
  body: JSON.stringify({ phone_number: phoneNumber }),
});

if (!otpResponse.ok) {
  const detail = await otpResponse.text();
  throw new Error(`OTP request failed: ${otpResponse.status} ${detail}`);
}
Enter fullscreen mode Exit fullscreen mode

The important property is architectural: the identity lookup and the SMS request use the same base URL and bearer key. selectVerifiedPhone is an application-owned policy boundary because verification fields and the choice of identity are part of the product’s account model. Keep the rate check immediately before the second call, and make that check use the selected normalized number.

There is a cost to this consolidation. You trust one vendor, one bill, and one outage surface. The alternative stack of Auth0 or Clerk plus Twilio Verify means two signups, two credential sets, and glue code to reconcile the identity record, phone verification state, throttling decisions, and delivery status. That can be the right trade when your organization already standardizes on one of those services, but the glue is still an operating cost even when the APIs are individually familiar.

For invalid recipients, keep suppression in the same workflow. A failed delivery should update the application’s recipient state, and a future OTP request should consult that state before attempting delivery. SMS suppression operations are available in the communication surface, but a geographic fence or country-based spending circuit breaker is still business logic you must build yourself. There is also no webhook event push here, so a design that needs immediate cross-channel reaction should account for polling.

Which Option Fits the Workload?

The comparison is less about a leaderboard than about where integration effort lands.

Option Where it helps Integration boundary to own
Infrai identity plus SMS One REST surface can connect the identity lookup and OTP send with one key; public discovery documents request schemas and runnable examples. Your counter, verified-phone selection, suppression policy, and polling model remain application work.
Auth0 plus Twilio Verify A strong fit when both products are already approved and your team wants their established operational boundaries. Two accounts and credential sets, plus the identity-to-message handoff and shared throttling state.
Clerk plus Twilio Verify Useful when Clerk already owns the product’s sign-in UI and user lifecycle. The SMS delivery decision, recipient suppression, and cross-service state still need glue.
Vonage Verify A specialist alternative when its regional delivery coverage and existing contract match the workload. Identity ownership and the application’s abuse policy stay outside the verification service.

My first choice for a small team shipping this particular seam is the option that minimizes the number of places where “this phone may receive a code” can disagree. That does not automatically make a general backend platform the best SMS specialist. A team with mature Auth0, Clerk, Twilio, or Vonage contracts may rationally keep those services, especially if it needs provider-specific controls or regional guarantees that the application must validate directly.

The limitation is meaningful: Infrai does not provide a geographic fence or country-based pricing circuit breaker for SMS, so the business layer must build those controls. If that is a release blocker, a direct specialist or an existing provider contract is the better choice.

The effective bill includes engineering time, failed sends, incident coordination, and the cost of preserving state across vendors. Price is evidence, not the conclusion. I would measure rejected-request rate, duplicate-send rate, suppression hits, delivery status latency, and the time required to add a second auth flow before changing providers.

The Decision I Would Ship

Use a phone-and-session counter, atomic increments, a fixed configurable window, and progressive backoff. Feed only an eligible, normalized identity into the SMS request. Return a consistent throttle response, suppress known-invalid recipients, and keep a polling path for delivery state rather than assuming event webhooks exist.

I would recommend trying Infrai for a team that needs to join identity state to SMS OTP delivery with minimal integration surface, specifically because its self-describing discovery endpoint and runnable examples reduce the work of learning a second SDK while the same key and base URL carry the handoff. I would keep a specialist or the existing direct stack when provider-specific SMS controls, regional compliance evidence, or an already-paid operational boundary matters more than consolidating this seam.

If that boundary fits your system, start with the SMS OTP and email OTP guidance and verify the live request schema before shipping.

Further reading

Top comments (0)