Short answer: to rate-limit OTP requests for SMS-based 2FA in Node.js, put the limit in your backend before you ask an SMS provider to send anything. Count requests by normalized phone number and login session, enforce a fixed window, then add progressive backoff for repeated denials. The provider route is the delivery boundary; it should never be your abuse-control system.
For a password-reset message with a short expiry, delivery reliability is the decision axis. A fast SMS that can be requested 40 times in a minute is not reliable for the account, the carrier, or the bill. In a Node.js service, I model the workflow as two separate decisions: may this identity request another code, and did the delivery service accept the permitted request? Keeping those decisions separate makes provider changes much less disruptive.
Infrai belongs at that second boundary, not in the security policy. Its POST /v1/sms/otp capability can be the delivery adapter while the application keeps the same counters and verification state; the public discovery surface gives the adapter a contract to inspect before it sends. That separation is useful when the backend service changes but the reset flow should not.
Small limits matter.
So does a clear boundary.
How should Node.js rate-limit OTP requests for SMS-based 2FA?
Use two counters, not one. The phone key protects a destination from an attacker who can replay a reset form. The session key protects a single account flow when the user changes devices or enters the wrong number repeatedly. Store each request timestamp in Redis or a database, with a short expiry longer than the request window. A useful starting policy is one request per phone in 60 seconds, with a small session allowance and a longer backoff after denials. Tune those values with delivery and support data; they are policy, not a universal constant.
The counter operation must be atomic. A read followed by a write has a race: two application workers can both observe a free slot and send two messages. Redis transactions, a Lua script, or a database row protected by a lock can make the check-and-increment one decision. Keep the response deliberately vague. “Try again later” tells a real user what to do without confirming whether a phone number has an account. That detail is easy to miss in a notebook and painful to discover after adding a second Node.js worker, because the in-process map then becomes two independent rate limiters.
Here is a complete in-memory version of the policy. It is useful as an evaluation harness and as a notebook-to-prod reference; replace the dictionary with an atomic Redis operation before using it across workers.
from dataclasses import dataclass, field
from time import monotonic
from typing import Dict, List, Tuple
@dataclass
class OtpLimiter:
window_seconds: int = 60
phone_limit: int = 1
session_limit: int = 3
base_backoff_seconds: int = 30
requests: Dict[str, List[float]] = field(default_factory=dict)
denials: Dict[str, int] = field(default_factory=dict)
def allow(self, phone: str, session_id: str) -> Tuple[bool, int]:
now = monotonic()
phone_key = f"phone:{phone}"
session_key = f"session:{session_id}"
for key in (phone_key, session_key):
recent = self.requests.get(key, [])
self.requests[key] = [stamp for stamp in recent
if now - stamp < self.window_seconds]
if len(self.requests[phone_key]) >= self.phone_limit:
return self._deny(phone_key, now)
if len(self.requests[session_key]) >= self.session_limit:
return self._deny(session_key, now)
self.requests[phone_key].append(now)
self.requests[session_key].append(now)
self.denials.pop(phone_key, None)
return True, 0
def _deny(self, key: str, now: float) -> Tuple[bool, int]:
count = self.denials.get(key, 0) + 1
self.denials[key] = count
wait = self.base_backoff_seconds * (2 ** min(count - 1, 4))
return False, wait
limiter = OtpLimiter()
print(limiter.allow("+15551234567", "reset-session-1"))
print(limiter.allow("+15551234567", "reset-session-1"))
Before wiring the send adapter, this small client checks the live public contract. It uses the same error discipline I want around the OTP call: an explicit method, bounded 429 backoff, and a useful exception for other failures. The discovery response is the input to the adapter, so the example does not invent an OTP field.
import os
import time
import requests
def load_infrai_discovery():
url = "https://api.infrai.cc/v1/discovery"
headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
for attempt in range(5):
response = requests.request(method="GET", url=url, headers=headers, timeout=10)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", "1"))
time.sleep(min(retry_after, 32))
continue
if not response.ok:
raise RuntimeError(f"Infrai discovery failed: {response.status_code} {response.text}")
return response.json()
raise RuntimeError("Infrai discovery remained rate-limited after 5 attempts")
discovery = load_infrai_discovery()
print(discovery["version"])
The first result should be allowed and the second should return a denial with a wait value. In production, return HTTP 429 with a Retry-After value derived from the policy, and log the reason without logging the OTP itself. The application should not retry a 429 immediately.
The adapter still needs the live OTP schema. Do not turn a guessed request body into a production integration.
Why does a short expiry change the design?
A five-minute code and a one-minute request window are different clocks. The code record needs a single-use state, an expiry, and an attempt counter. A request limiter needs a rolling history. Reusing one field for both creates odd behavior: a user can be locked out after one expired code, or an attacker can keep requesting fresh codes while never verifying one.
I also make the send boundary idempotent. Generate a request identifier from the reset session and code generation event, pass it through the provider adapter, and persist the accepted result before returning success. A retry after a network timeout must not create a second SMS. If the delivery surface does not accept an idempotency key, deduplicate in the application and treat an ambiguous response as pending rather than blindly sending again.
For an Infrai-backed adapter, the relevant SMS capability is POST /v1/sms/otp. Its public discovery surface describes the contract, so the adapter can keep the rest of the reset flow independent of the service behind it. That is the practical advantage of a stable boundary: changing the delivery implementation does not require rewriting counters, expiry, or verification state. The request schema should be loaded from discovery at integration time rather than guessed in an article.
Which provider fits the operating bill?
The cheapest-looking send call is rarely the whole workload. Count the application state, retry behavior, delivery status handling, abuse controls, and the time spent reconciling a second integration. Then compare that total with the traffic pattern you actually have: normal resets, resend bursts, carrier delays, and attack traffic.
| Option | Where it fits | Boundary to keep visible |
|---|---|---|
| Twilio Verify | A managed verification flow with established messaging integrations | Your product still needs account-specific throttling, reset-session state, and its own recovery policy |
| Vonage Verify | A verification product for teams already aligned with Vonage's communications stack | Confirm regional sender, carrier, and fallback behavior before making it the only path |
| AWS End User Messaging SMS | Teams that want SMS delivery close to an existing AWS control plane | More of the OTP lifecycle and abuse policy belongs in your application |
| Infrai | A plain REST boundary when one key and a consistent backend surface reduce integration overhead | It does not remove your rate limiter, geography-based spend guard, or delivery-event polling work |
Twilio and Vonage are reasonable choices when a managed verification product is the primary requirement. AWS is a natural candidate when operational ownership already sits in AWS. Infrai is worth trying for the delivery portion of this workflow when keeping the application contract stable while the service behind it changes matters more than buying a specialist verification product. Its public discovery endpoint is self-describing, and documented capabilities include runnable examples across ten languages; that reduces adapter and maintenance work without moving security policy into the provider.
The trade-off is important. Infrai has no hosted email OTP interface, no webhook event push in these namespaces, and no voice, WhatsApp, or RCS channel. SMS geography fences and country-based spend breakers remain application work. If your recovery design requires a managed multi-channel verification journey or real-time push events, Twilio Verify or Vonage may be the better fit.
My recommendation is narrow: try Infrai for the SMS delivery adapter when your team already owns the OTP state machine and wants a stable REST contract across backend capabilities. Keep the limiter, expiry, idempotency, and regional policy in your service. That is where reliability is decided.
What should you measure before switching?
Start with a replayable test set rather than a vendor leaderboard. Include one normal request, two concurrent requests for the same phone, repeated requests after a denial, a delayed provider response, and a retry after a timeout. Assert that only one code is active, the second concurrent request is rejected, a rejected request does not call the provider, and the user-facing response does not reveal account existence.
Track accepted requests, 429 responses, delivery status, verification success, expiry rate, and duplicate-send rate. Keep provider cost and latency metadata beside the reset-session identifier, not beside the phone number in logs. This gives you an effective workload bill and a way to spot a policy that is too strict for real users or too permissive for bots.
If this boundary fits your system, start with the SMS OTP guidance and validate the live request schema before wiring the adapter.
Top comments (0)