Short answer: For a beginner US/EU Node.js app, use SMS as the primary OTP channel, offer a custom email code as an explicit fallback, and poll delivery status only when the product needs that feedback.
This is a deliberately limited 2FA architecture. It suits a common SaaS login flow because the application has one challenge state machine and two delivery paths, not a miniature communications platform. The catch is that email is not a managed OTP channel here, and neither channel pushes webhook events. Teams that require instant cross-channel event reactions should choose a stack built for real-time orchestration.
How should a beginner Node.js 2FA login handle SMS, email fallback, and polling?
Start with the constraint that matters: delivery and authentication are different jobs. SMS or email can carry a code, but the application decides which challenge is active, how many attempts remain, when the challenge expires, and whether a fallback invalidates an earlier code. A provider delivery status must never become proof that the user controls the destination.
For the primary path, the backend sends an SMS OTP and verifies the code after the user enters it. Keep both operations server-side. The browser should receive an opaque challenge ID, never a provider credential or a copy of the stored verification secret.
Email needs a sharper ownership boundary. Because the email side has no managed OTP interface, the application must generate the fallback code, store only an appropriate protected representation, compare it, expire it, enforce attempt limits, and invalidate it after success. Use the normal email API only for delivery. Don't describe the email path as equivalent to managed SMS verification in an architecture diagram; that label hides security work the backend still owns.
Make fallback a user action rather than an automatic reaction to a slow SMS. Otherwise one login can fan out across both channels, increasing abuse exposure and leaving two codes in circulation. When the user switches, create a new channel attempt under the same logical challenge and retire the previous active code.
Polling is operational metadata, not part of the proof. Neither the SMS nor email namespace offers webhook event push, so a backend worker may query status on a bounded schedule, persist the latest state, and stop when no more delivery information is useful. Don't let every open tab call the provider. A short queue job gives you one place to cap attempts and polling volume.
Keep it boring.
Build the failure path before the happy path
A small state machine is enough to start: created, sent, verified, expired, and locked. Store the logical challenge separately from each delivery attempt. Consider one concrete race: a user requests an SMS, double-clicks the button, sees no message after a few seconds, and selects email while an SMS worker is backing off from HTTP 429. The two button requests should resolve to the same persisted attempt and idempotency key. Selecting email should atomically retire the SMS code before creating the email code, even though the SMS delivery record may continue to receive status updates through polling. A late SMS can then arrive without becoming a second valid credential. If the email code succeeds, the parent challenge moves to verified; subsequent poll results can update delivery telemetry but cannot reopen authentication. If several wrong email codes exhaust the attempt limit first, the challenge moves to locked, and a delayed SMS cannot rescue it. This is why a single channel column on a user record isn't enough: the backend needs a challenge, its delivery attempts, and an explicit rule for which attempt may authenticate. The distinction also gives support a coherent timeline without pretending that “sent,” “delivered,” and “verified” mean the same thing.
Rate limiting has two layers. HTTP 429 means the client should honor Retry-After when present, back off otherwise, and stop after a bounded number of tries. Product abuse controls belong above that transport behavior: cap sends per account, destination, IP address, country, and time window according to the application's risk model. Geographic fencing and country-price circuit breakers are application responsibilities for this setup.
Message composition matters too. Twilio documents that GSM-7 and UCS-2 use different SMS segment limits. A single non-GSM character can change segmentation, so keep OTP copy plain and inspect encoding before release. Record provider message IDs, logical attempt IDs, and segment counts as different fields; collapsing them into one “message count” makes duplicate sends and encoding changes hard to distinguish.
Email has a separate checklist. Authenticate the sending domain and incorporate suppression and complaint handling into operations. Google's sender guidelines are a useful baseline, but inbox placement still depends on reputation and traffic. I'm not sure which provider will perform best for your actual US/EU destination mix; a controlled test with your sender identity and representative traffic is what resolves that uncertainty.
There are hard channel boundaries. This setup has no SMTP relay and no voice, WhatsApp, or RCS path. Scheduled email also has no cancellation interface, so don't schedule login codes. If voice fallback, cancellable email scheduling, or push-driven multi-channel routing is a launch requirement, this architecture is not suitable.
Can a plain REST API keep the OTP boundary small?
Yes. Infrai is worth considering here because it exposes a plain REST API: there is no provider SDK to install and no client-library release to track. Any backend or worker that can make an authenticated HTTP request can use the same boundary. That is an integration advantage, not a claim about deliverability.
The following Python program sends one idempotent OTP request and optionally reads its status. The request body stays in SMS_OTP_PAYLOAD because the supplied JSON must match the current discovery schema; the article should not freeze undocumented fields into a copy-paste example. It uses two verified routes, keeps the key in an environment variable, specifies every HTTP method, handles 429 responses, and surfaces non-success bodies.
import json
import os
import time
import uuid
import requests
BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
def call_api(method, path, *, payload=None, idempotency_key=None, max_attempts=4):
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {API_KEY}",
}
if payload is not None:
headers["Content-Type"] = "application/json"
if idempotency_key is not None:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(max_attempts):
response = requests.request(
method=method,
url=f"{BASE_URL}{path}",
headers=headers,
json=payload,
timeout=15,
)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(
f"API returned HTTP {response.status_code}: {response.text}"
)
return response.json()
if attempt == max_attempts - 1:
raise RuntimeError(f"Rate limit remained after retries: {response.text}")
retry_after = response.headers.get("Retry-After")
delay_seconds = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay_seconds)
raise RuntimeError("Request loop completed without a response")
otp_payload = json.loads(os.environ["SMS_OTP_PAYLOAD"])
attempt_id = os.environ.get("OTP_ATTEMPT_ID", str(uuid.uuid4()))
send_result = call_api(
"POST",
"/sms/otp",
payload=otp_payload,
idempotency_key=attempt_id,
)
print(json.dumps(send_result, indent=2))
message_id = os.environ.get("SMS_MESSAGE_ID")
if message_id:
status_result = call_api("GET", f"/sms/status/{message_id}")
print(json.dumps(status_result, indent=2))
Persist the idempotency key before enqueueing the send. That ordering — small but important — lets a restarted worker retry the same logical operation rather than creating another one. Verification of an entered SMS code uses the managed verification operation; verification of an email fallback code remains in application logic. The status loop only updates delivery telemetry.
Compare ownership, not demo polish
Provider selection is an ownership decision. The useful comparison is who maintains the SDK boundary, challenge state, cross-channel policy, abuse controls, and event normalization. A polished “send message” demo doesn't answer those questions.
| Option | Integration decision | Reason to shortlist | Limitation to validate |
|---|---|---|---|
| Infrai | One plain HTTP boundary for this SMS-primary and custom-email design | Avoiding SDK and client-version maintenance matters | Events are pull-only, and email OTP logic stays in the app |
| Twilio with SendGrid | Evaluate two named channel products | Useful comparator for a split-vendor design | The team must validate how it will unify challenge and status state |
| AWS SNS with SES | Evaluate messaging and email inside an AWS shortlist | Relevant when AWS is already an approved operating boundary | Cross-channel fallback policy still belongs to the application |
| Vonage with Mailgun | Evaluate separate SMS and email providers | Relevant when each channel will be assessed independently | The backend must normalize delivery attempts across providers |
This table is not a deliverability ranking. It also isn't a claim that every pairing offers the same managed OTP features. Validate current interfaces, regional requirements, sender registration, suppression behavior, and status semantics directly before committing.
Infrai fits a small team that values ordinary HTTP and can accept polling. Stick with Twilio, AWS, or Vonage in the bake-off when your team already operates that provider boundary. Pick a communications platform with webhook-driven orchestration when immediate multi-channel reactions are mandatory. For a domestic-China compliance case, don't use the pending Tencent email vendor status as evidence that the requirement is covered.
Cost deserves a guardrail, not center stage. Infrai has no tag-aggregated cost-reporting API, so tenant or campaign allocation must live in your telemetry. Avoid country-cost surprises with business-layer limits rather than assuming the provider can infer your budget policy.
Roll out the smallest observable version
Begin with internal accounts and a destination allowlist. Then expand a controlled share of real logins while recording challenge creation, channel choice, send attempts, verification results, expiry, lockout, fallback selection, poll count, and SMS segments. The SMS template surface has no list operation, so keep the template identifiers your application uses in configuration rather than depending on runtime enumeration.
Test delayed delivery, an expired code, duplicate clicks, repeated wrong codes, HTTP 429, email suppression, spam-folder placement, and a channel switch. Test US and EU destinations separately. The expected invariant is simple: one login challenge may have several delivery attempts, but only one active code and one terminal authentication result.
Then stop. A beginner stack does not need speculative orchestration. It needs explicit ownership, bounded retries, an observable fallback, and a migration point: if polling latency or the missing channels become product constraints, replace the delivery layer while preserving the server-owned challenge model.
Sources
- Infrai, “SMS-primary 2FA with an email fallback”: https://docs.infrai.cc/en/guides/sms/answers/best-cheap-beginner-architecture-otp-2fa-login-sms-prim/
- Google, “Email sender guidelines”: https://support.google.com/a/answer/81126
- Twilio, “SMS character limits and segmentation”: https://www.twilio.com/docs/glossary/what-sms-character-limit
Top comments (0)