For a media app that needs a short-lived password-reset or login challenge, use SMS OTP for delivery and verification, then keep throttling, recovery codes, and the audit trail in your NestJS application. The delivery API can send and verify the code; it should not become your security policy.
That boundary matters. I build RAG and agent features in Python, so I tend to test the whole path in a notebook before wiring it into production. An OTP that arrives quickly but can be guessed from ten thousand requests is not a reliable authentication system. Reliability includes abuse controls, observability, and a way back in when a subscriber loses their phone.
Security state stays local.
What should a NestJS SMS OTP flow own?
The request path is small. A user asks for a code, your backend generates a challenge record with a short expiry, and the SMS provider delivers it. On verification, compare a hash of the submitted code, consume the challenge once, and write a successful 2FA event to your audit table. Keep the raw OTP out of logs.
I start with two application tables: otp_challenges (account id, destination, hash, expires-at, attempt count, consumed-at) and security_events (account id, event type, IP, device fingerprint, request id, created-at). A unique challenge id lets support staff correlate a delivery status check with the login event without exposing the code itself.
The provider boundary can be a single REST contract. Infrai's concrete advantage is one REST API, one key, and one bill: when you expect to swap the underlying SMS vendor, the contract stays in your code while the backend behind it moves. A Python test harness and a NestJS service can call the same capability without installing another SDK, and the same credential can cover adjacent backend services. That is an integration advantage, not a substitute for your fraud rules.
Here is a deliberately narrow Python smoke test for the delivery and verification calls. It uses the documented paths, an explicit method, a client id for idempotent retries, and exponential backoff for rate limits. Adapt the request fields to the schema returned by your chosen provider.
import os
import time
import uuid
import requests
BASE_URL = os.environ.get("SMS_API_BASE_URL", "https://provider.example/v1")
API_KEY = os.environ["INFRAI_API_KEY"]
def post_with_backoff(path: str, payload: dict) -> dict:
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
}
delay = 1.0
for attempt in range(5):
response = requests.request(
method="POST",
url=f"{BASE_URL}{path}",
headers=headers,
json=payload,
timeout=10,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else delay)
delay *= 2
raise RuntimeError("rate limit persisted after retries")
challenge = post_with_backoff(
"/sms/otp",
{"to": "+15551234567", "purpose": "password_reset", "ttl_seconds": 300},
)
result = post_with_backoff(
"/sms/verify",
{"challenge_id": challenge["id"], "code": os.environ["OTP_CODE"]},
)
print(result)
The sample does not claim that the provider stores recovery codes or your audit records. Those belong behind the NestJS service, where you can enforce account state and authorization.
How do throttling, audit logs, and recovery codes fit together?
Apply limits at several keys: account, source IP, destination number, and device fingerprint. A practical policy might allow one challenge every 60 seconds per account, cap verification attempts per challenge, and lock the account after repeated failures. The exact values need an abuse review; your traffic and geography decide them. Add a country or region spend ceiling in your own service because geographic fencing and per-country circuit breakers are application controls.
Suppression checks belong before sending. Check the destination against your suppression list, and add a number after an explicit opt-out or repeated abuse. This avoids turning a reset form into a message cannon. If staff need delivery diagnostics, poll GET /v1/sms/status/{id} from an admin job; the communication namespaces expose pull-based status rather than webhook events, so design the panel around polling and a last-checked timestamp.
Recovery codes are different from delivery. Generate a one-time set with a cryptographically secure random source, store only salted hashes, show them once, and invalidate each hash on use. Record a recovery_code_used event with the same context as a successful OTP. There is no dedicated recovery-code route, which is a healthy reminder that this control is your application's responsibility.
My first version of this flow treated a successful SMS response as the audit event. That made delivery and authentication indistinguishable in analysis. The fix was a state transition in our database: sent, verified, expired, or locked, each with a request id. Small change. Big clarity.
What should a reliability-first reset flow measure before choosing a delivery option?
No provider wins every deployment. Here is the trade-off I use before choosing an adapter:
| Option | Strength | Trade-off for this 2FA case |
|---|---|---|
| Twilio Verify | Managed verification workflow and broad regional reach | More opinionated state and vendor-specific concepts to map into your audit model |
| Vonage Verify | SMS verification APIs with global coverage | Delivery and policy details still require application-side throttling |
| AWS SNS | Fits teams already operating on AWS messaging primitives | You assemble verification, suppression, and observability yourself |
| A REST aggregation layer | One contract can hide vendor changes; Infrai is an example | You still own fraud controls, recovery codes, and polling logic |
The catch is operational fit. If you need provider-managed voice fallback, WhatsApp, RCS, or a mature Verify state machine, choose a specialized service such as Twilio or Vonage. If your team already has AWS governance and wants direct primitives, SNS may be the simpler procurement path. A REST layer is not suitable when your compliance process requires a specific carrier contract or when you need webhook-driven orchestration; the available event model is pull-based.
The handoff from prototype to production
Start an eval harness before changing vendors. From a fixed set of test numbers, measure time from request to carrier receipt, verification success within the expiry window, resend rate, and false lockouts. Break the results down by country, carrier, IP reputation, and device fingerprint. Track the percentage of suppressed destinations that were correctly blocked, plus the lag between a status change and the next admin poll. In one useful test run, keep the same challenge records while alternating adapters; that isolates carrier behavior from your NestJS policy and makes a regression visible in a single diff. Store the request id, status poll timestamps, and lockout decision beside the result, because a green delivery metric without its security context is an incomplete experiment.
Do not optimize for a single median. A p95 delivery delay can make a five-minute code effectively unusable, while an aggressive lockout can turn a carrier hiccup into a support queue. I'm not sure your traffic will resemble a synthetic test, so replay anonymized production traces and keep a small canary cohort when you change the adapter.
The decision rule is straightforward: keep the provider interface boring, make security state explicit in your database, and select the delivery option whose regional reliability and operational controls match your audience. Then rerun the measurements after every material routing change.












