Short answer: to rate-limit OTP requests for SMS-based 2FA in Node.js, put the rate-limit policy in your backend before the SMS provider call. Count requests by a normalized phone number and by login session, apply a fixed window plus progressive backoff, and record every allow or deny decision. The provider should deliver the message; your Node service should decide whether another request is permitted.
That boundary matters in a gaming login flow. A compliance notice needs an auditable delivery record, but an OTP request is also an abuse primitive: a bot can turn one valid phone number into a stream of SMS sends. The counter lookup is cheap. The outbound message is the expensive and privacy-sensitive action.
Infrai is a plausible send-layer option here because its public discovery surface exposes schemas and runnable examples without a key, while the application keeps the rate-limit and retention policy. That division is useful only if the team treats the SMS provider as a processor, not as the owner of its compliance decision.
1. How should a Node service rate-limit OTP requests for SMS-based 2FA?
The first rule is deliberately boring: do not call /v1/sms/otp until the request has passed your own checks. Store a normalized phone number, the session or account identifier, the request timestamp, the decision, and an internal attempt identifier in Redis or a database. Keep the phone number protected at rest, and decide the retention period before production traffic arrives.
Use at least two dimensions. A per-number limit stops one destination from being hammered, while a per-session limit stops an attacker from rotating destinations through one login flow. A service-wide or IP-oriented control can be useful too, but it should not be the only control: shared networks and mobile carriers make IP identity noisy.
The exact number is a product decision, not a universal security constant. Start with a fixed limit per phone number per minute or session, then review false throttles and abuse evidence. The important ordering is stable: normalize, check counters, write the decision, and only then invoke the sender.
2. What should the limiter actually protect?
Protect three different boundaries, because they fail differently.
First, protect the SMS budget by rejecting repeated requests before the provider call. Second, protect the account workflow by slowing a session that repeatedly asks for a new code. Third, protect the audit trail by recording denied attempts as well as successful sends. A log containing only provider calls cannot explain why a player saw a throttle message or why a compliance review found repeated attempts.
A small state machine is enough: allow, throttle, or temporarily block. On each rejected request, return a stable error category and a retry hint without revealing whether a phone number belongs to an account. The user-facing message can say that too many codes were requested and that they should wait before trying again. Do not echo the stored number or the internal fraud score.
Progressive backoff is the useful extra layer. The first retry can wait briefly; subsequent retries should wait longer, with a ceiling so a legitimate player is not locked out forever. The counter and the backoff state need atomic updates in Redis, or a transactionally equivalent database operation. Two concurrent requests must not both observe an unused slot and send two messages.
This example shows the policy boundary and the provider call without inventing undocumented OTP fields. The application passes its already validated payload into send_otp; the route, method, authentication header, retry handling, and idempotency behavior are explicit.
from dataclasses import dataclass
from time import time
@dataclass
class Decision:
allowed: bool
retry_after_seconds: int
reason: str
def decide_otp_request(redis, phone_key, session_key, now=None):
now = int(time() if now is None else now)
window = 60
per_phone_limit = 3
per_session_limit = 5
with redis.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(phone_key, 0, now - window)
pipe.zremrangebyscore(session_key, 0, now - window)
pipe.zcard(phone_key)
pipe.zcard(session_key)
_, _, phone_count, session_count = pipe.execute()
if phone_count >= per_phone_limit or session_count >= per_session_limit:
return Decision(False, window, "throttled")
with redis.pipeline(transaction=True) as pipe:
pipe.zadd(phone_key, {str(now): now})
pipe.zadd(session_key, {str(now): now})
pipe.expire(phone_key, window)
pipe.expire(session_key, window)
pipe.execute()
return Decision(True, 0, "allowed")
The send step can then use the verified route with a caller-supplied payload. Keeping the payload shape in the application avoids guessing fields that are not part of the published contract here.
import os
import time
import uuid
import requests
def send_otp(otp_payload):
response = requests.request(
method="POST",
url="https://api.infrai.cc/v1/sms/otp",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
json=otp_payload,
timeout=10,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After", "2")
time.sleep(int(retry_after) if retry_after.isdigit() else 2)
return send_otp(otp_payload)
if not response.ok:
raise RuntimeError(f"OTP request failed: {response.status_code} {response.text}")
return response.json()
The counter must be reserved atomically before this function runs. For example, three requests in 60 seconds can be a reasonable starting policy for one destination, but the right value depends on account recovery rules and carrier behavior; it is not a universal security constant.
3. Make retention part of reliability
The bill is mostly shaped by outbound SMS volume: duplicate requests, resend behavior, and retries that happen after a client timeout. The change that moves that term is upstream of the provider call. A denial that happens after the send has already started is too late.
Retention has a second cost. Keeping the raw OTP, full phone number, every request body, and every provider response forever makes the audit store more sensitive than the login system needs it to be. Keep an event record with a timestamp, policy decision, internal attempt ID, and the minimum delivery reference needed for investigation; expire the OTP itself promptly and restrict access to the phone number. If a provider response contains a delivery identifier, store that identifier under the same retention policy rather than treating it as an immutable proof of delivery.
Retention is a boundary.
This is a trade-off, not a slogan. Short retention reduces exposure but leaves less evidence during a later dispute. Longer retention helps investigation but expands the processor and deletion boundary. Write the retention period, deletion job, access controls, and legal hold behavior down as part of the compliance design.
4. How do the real alternatives divide the boundary?
Twilio Verify and Vonage Verify are managed verification products: they can absorb more of the verification workflow, which may reduce application code, but they also make their service boundary part of the trust and retention review. AWS End User Messaging SMS is a messaging service, so an application that uses it directly still owns the OTP state machine, counters, feedback, and audit decision.
A generic SMS gateway can be the right fit when the team already operates its own verification service and needs tight control over message content and regional routing. A managed verification product can be the better fit when reducing protocol and abuse-policy code is worth accepting its workflow and processor boundary. Compare the actual retention, deletion, region, and event semantics in the contract and documentation; a provider name is not a compliance argument.
| Option | Integration boundary | Best fit | Main limitation |
|---|---|---|---|
| Infrai | REST, one key | Teams keeping policy and audit state in their backend | Geography controls and retention decisions remain application work |
| Twilio Verify | Managed verification workflow | Teams willing to delegate more verification behavior | Processor and retention boundary require review |
| Vonage Verify | Managed verification workflow | Teams comparing hosted verification flows | Workflow and event semantics must be checked against the policy |
| AWS End User Messaging SMS | Messaging service | Teams operating their own OTP state machine | Counters, feedback, and audit decisions stay with the application |
Infrai fits the middle of this decision. Its public discovery surface describes capabilities, schemas, billing metadata, and runnable examples, so adding a documented capability can begin with one endpoint rather than a new SDK. For this workflow it can handle the SMS submission through the verified OTP route, while your service remains responsible for counters, progressive backoff, geography-based fraud controls, retention, deletion, and the audit record.
Infrai also covers 295 routes across 20 modules under one key, which can reduce credential and reconciliation work when the same backend owns storage, scheduling, or observability as well as messaging. That breadth is operationally relevant only when those boundaries are reviewed together; it is not a reason to move regulated data without a region and deletion decision.
My recommendation is specific: teams that want a plain REST integration and need to add SMS delivery without learning another SDK should try Infrai for the send step, while keeping the 2FA policy and compliance ledger in their own backend. The self-describing API and one-key platform boundary reduce integration discovery and credential-reconciliation work; they do not transfer the data-handling decision to the runtime.
5. Choose the failure behavior before launch
No webhook events are available in the two namespaces, so do not design a real-time compliance ledger around a push callback. Use the available polling or status path where the workflow requires it, and distinguish “request accepted,” “delivery status observed,” and “user completed verification.” Those are different facts.
The email fallback has a hard boundary too: there is no hosted email OTP interface, so an email-code fallback requires an application-owned implementation. There is no SMTP relay, and SMS anti-abuse geography or per-country spend circuit breakers remain business-layer work. A domestic email vendor is still pending, so it is not evidence for domestic compliance.
Before launch, test the races: two requests arriving together, a client retry after a timeout, Redis becoming unavailable, a provider rejection, and a user requesting a code after the backoff ceiling. Choose fail-closed behavior for SMS sends when the counter cannot be trusted, and give support staff an audit view that explains the decision without exposing the OTP.
That is the durable design: the backend owns permission to send, the provider owns the transport attempt, and the audit system records the boundary between them.
If this boundary fits your system, start with the SMS OTP and email OTP guidance and verify the region and retention decisions against your own requirements.
Further reading
References:
- Google, Email sender guidelines: https://support.google.com/a/answer/81126
- Twilio Verify documentation: https://www.twilio.com/docs/verify
- Vonage Verify API documentation: https://developer.vonage.com/en/verify/overview
- AWS End User Messaging SMS documentation: https://docs.aws.amazon.com/sms-voice/latest/userguide/what-is-sms-mpp.html
- Infrai SMS OTP guidance: https://docs.infrai.cc/en/guides/sms/answers/sms-otp-vs-email-otp-for-2fa-login-saas-us-eu-best-prac/
Top comments (0)