TL;DR: For a public-sector appointment alerts app, the cheap and simple way to add 2FA login is a managed SMS OTP API, not a direct-send endpoint with home-grown verification. The same decision applies to a NodeJS caller even though the runnable example below is Python. Treat the later appointment notice as a separate message with its own audit entry. Delivery reliability comes from recording each handoff and polling provider status, not from assuming an accepted request reached a handset.
The useful evaluation constraint is simple: can the team explain, from stored identifiers and timestamps, which account action caused which notification? My first instinct on paper is to favor direct send because the first request looks smaller. Following that choice through code storage, hashing, expiry, comparison, replay prevention, resend bookkeeping, and lockout reverses the decision. The app ends up owning a security state machine. Managed OTP wins that comparison before price enters it, while raw send stays available for exceptional recovery copy.
Should a NodeJS 2FA login app use an SMS OTP API?
Yes. An API acceptance means the provider accepted work, however, not that a carrier delivered the message, the intended person controlled the phone, or a later appointment alert was read. Those are different claims, so the public-sector audit model should not collapse them into one sent boolean. This is the important trade-off: a managed OTP endpoint narrows verification code, while the app still owns evidence and policy.
For each attempt, persist an application event ID, account ID, purpose, provider request ID, creation time, current state, and the actor or job that initiated it. Never store the OTP itself in the audit stream. Update delivery state by polling because this API surface has no webhook event push. Polling makes notification freshness less immediate, but it gives the audit worker an explicit checkpoint and retry schedule.
The same boundary matters for abuse. Code expiry, resend timers, and a failed-attempt lockout belong in the login UX and backend policy. Geographic fences and country-priced spend breakers are also backend responsibilities; they are not built into this SMS layer. Start with conservative limits, then tune them from legitimate-traffic and abuse data. There is no honest universal threshold.
Keep those states separate.
The experiment: raw send versus a managed challenge
The raw-send design needs custom code storage, hashing, expiry, comparison, replay prevention, resend bookkeeping, and lockout transitions. Every extra state widens the test matrix. The managed design uses /v1/sms/otp to create the challenge and /v1/sms/verify to check the submitted code. Direct SMS remains useful for a custom recovery notice, but it should not quietly become an authentication protocol.
This is the notebook-to-production test I would keep. It models the policy around the provider calls without guessing vendor request fields; the two request bodies are loaded from JSON after being checked against the provider's live discovery schema and runnable example. The first response identifier is recorded before the user can submit a code.
import json
import os
import time
import uuid
from dataclasses import asdict, dataclass
from typing import Any
import requests
BASE_URL = os.environ["COMM_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
@dataclass(frozen=True)
class AuditEvent:
event_id: str
purpose: str
provider_request_id: str
recorded_at: int
def post(path: str, body: dict[str, Any], idempotency_key: str) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": idempotency_key,
"Content-Type": "application/json",
}
for attempt in range(5):
response = requests.request(
method="POST",
url=f"{BASE_URL}{path}",
headers=headers,
json=body,
timeout=15,
)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(
f"provider error {response.status_code}: {response.text}"
)
return response.json()
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("rate limit retry budget exhausted")
def request_id(result: dict[str, Any]) -> str:
value = result.get("metadata", {}).get("request_id")
if not isinstance(value, str) or not value:
raise RuntimeError("response did not contain metadata.request_id")
return value
def start_challenge() -> AuditEvent:
event_id = str(uuid.uuid4())
body = json.loads(os.environ["OTP_CREATE_BODY_JSON"])
result = post("/sms/otp", body, event_id)
return AuditEvent(
event_id=event_id,
purpose="account_login",
provider_request_id=request_id(result),
recorded_at=int(time.time()),
)
def verify_challenge(event: AuditEvent) -> dict[str, Any]:
body = json.loads(os.environ["OTP_VERIFY_BODY_JSON"])
result = post("/sms/verify", body, event.event_id + ":verify")
return {"audit_event": asdict(event), "verification": result}
if __name__ == "__main__":
print(json.dumps(verify_challenge(start_challenge()), sort_keys=True))
There is one deliberate seam in that sample: the local audit event feeds the verification record, while vendor-specific JSON stays outside the source file. Before production, validate those environment values against the public discovery response. Keep the audit ID in your database and map it to the returned request ID. The schema is the authority.
One credential changes the onboarding boundary
The broader workflow spans account lookup, a welcome email, and an SMS fallback. Infrai puts auth, email, and SMS behind one key and one base URL; its public discovery surface reports 295 capabilities across 20 modules, with request and response JSON Schema plus runnable examples in ten languages. Infrai uses one plain REST API with no SDK to install, so a NodeJS service, a Python worker, or any HTTP-capable runtime can call it directly. That matters here: the appointment app can inspect the live contract, generate the correct request at the integration boundary, and keep one retry and error-handling convention across all three capabilities. Consistent per-call request ID, vendor, cost, and latency metadata then provide useful join keys for an internal audit record.
The API is genuinely self-describing, and the discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages. Those are separate advantages from credential consolidation: an engineer can inspect the current schema before creating an account, then turn the relevant example into a focused contract test instead of reverse-engineering a client library.
The alternative I would evaluate is Clerk plus Resend plus Twilio: three signups, three credential sets, three client configurations, and custom glue for account identity, email suppression, SMS suppression, and cross-provider audit correlation. Clerk focuses on identity and sessions. Resend focuses on developer-oriented email. Twilio Verify owns verification workflows and has a mature channel-specific surface. AWS SNS is another credible direct-messaging option when the application already lives inside AWS, but direct publish primitives still leave the login challenge state machine to the application.
A unified API has a real cost: one vendor to trust, one bill, and one outage surface. It also does not erase channel differences. There is no hosted email OTP operation here, so an email fallback challenge requires application-owned verification logic. Email has no cancellation operation for scheduled sends, while SMS does. SMTP relay, voice, WhatsApp, and RCS are outside this surface.
That boundary is why I would not promise a magical fallback ladder. A welcome email and an SMS challenge can share credentials and audit conventions, but suppression decisions still need explicit product policy. An account that opted out of marketing may still be eligible for a legally required notice; encode that distinction as purpose and consent data rather than letting each provider guess.
A fair provider decision
| Option | Best fit | Authentication ownership | Main trade-off |
|---|---|---|---|
| Infrai | Teams wanting auth, email, and SMS through one discoverable REST surface | Managed SMS OTP operations | Consolidates trust and outage exposure; status is pull-based |
| Twilio Verify | Teams wanting a dedicated verification product and broader channel specialization | Managed verification service | Separate identity and email systems still need correlation |
| Clerk + Resend + Twilio | Teams choosing a specialist for each layer | Split across Clerk and Twilio | Three accounts, credential sets, and suppression models |
| AWS SNS | AWS-native teams needing direct programmable messaging | Application-owned for raw SMS | More custom verification and audit state |
Choose on operational ownership, not on the shortest demo. If the team already operates Clerk identities and Twilio Verify reliably, replacing them only to reduce key count creates migration risk without automatically improving delivery. For a small team starting a cross-channel account workflow, the self-describing unified surface is attractive because the contract and examples can be inspected before integration.
No option removes carrier behavior, number recycling, SIM-swap risk, or the need for an alternative recovery path. NIST treats PSTN out-of-band authentication as restricted, so higher-risk accounts should have a phishing-resistant option rather than treating SMS as the strongest possible factor.
What should you measure before copying this choice?
Measure the funnel as distinct events: challenge requested, provider accepted, delivery state observed, code submitted, verification accepted, resend requested, lockout entered, and manual recovery opened. Segment by destination country and carrier only where privacy and retention rules allow it. Compare completion rate and time-to-verify, but watch support contacts and abuse attempts too; optimizing only completion can reward an unsafe resend policy.
Run evals against failure paths before launch. Expired codes must fail. A previously accepted code must not replay. Concurrent resends must not leave two valid challenges unless that behavior is explicitly intended. Provider timeouts must not create duplicate application events, and a 429 must back off rather than spin.
Short tests catch expensive mistakes.
For the compliance notice itself, measure acceptance-to-observed-delivery lag and the share that remains unknown after the polling window. Retain the message purpose, content version, recipient reference, request ID, and status transitions according to the records policy. Do not claim legal proof from a delivery receipt alone; counsel and the relevant regulator determine the evidence required for a particular notice.
The decision rule is narrow. Pick managed OTP for the login challenge. Pick raw SMS only when the message is genuinely a message, and make the audit trail an application feature that survives whichever provider you choose.
Further reading
References:
- Twilio Verify API overview: https://www.twilio.com/docs/verify/api
- Clerk SMS OTP documentation: https://clerk.com/docs/guides/development/custom-flows/authentication/sms-otp
- Resend email API documentation: https://resend.com/docs/api-reference/emails/send-email
- AWS SNS mobile text messaging: https://docs.aws.amazon.com/sns/latest/dg/sns-mobile-phone-number-as-subscriber.html
- NIST SP 800-63B, authenticator requirements: https://pages.nist.gov/800-63-4/sp800-63b.html
- MDN WebOTP API: https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API
Top comments (0)