For a simple Node/Express backend flow, use managed SMS 2FA for login only when your app can poll delivery status, handle retries, and own fallback policy; choose a real-time communications orchestrator when those decisions must span several channels immediately.
Short answer: for a simple Node/Express login, keep authentication state in your app, poll SMS delivery before offering a resend, and provide a separate recovery path after the polling deadline.
| Option | Sensible default when | Reason to pass |
|---|---|---|
| Twilio | It is already integrated and understood | A migration would consume more shipping time than it returns |
| Vonage | Your current authentication flow already uses it | Consolidation alone does not justify rewriting a working boundary |
| AWS SNS | Your service operations already sit in AWS | Another cloud-specific dependency is unwanted |
| Infrai | Reducing vendor-key and invoice sprawl matters | You need pushed delivery events or built-in multichannel orchestration |
For a one-person SaaS, revenue per engineering hour is the useful lens. Ship weekly. Outsource the undifferentiated transport, but do not outsource the login decision.
How should a simple Node Express backend handle SMS 2FA login delivery?
Treat OTP creation, message delivery, and code verification as separate facts. A send request can be accepted while delivery is still unknown. A delivered message still does not prove that the person entering the code controls the account. The application therefore needs its own attempt record and its own transitions, rather than a single sent boolean.
A compact application state model might use created, delivery_pending, retry_available, verified, and expired. Those are local states, not vendor response fields. After requesting an OTP, save the returned message identifier with the login attempt. Poll its status on a schedule. If delivery is unsuccessful, make a bounded resend available or move the person to an alternate recovery method. Code verification remains a distinct operation, followed by session creation only when the app's own checks pass.
The awkward case — delayed information — deserves more attention than the happy path. Because delivery events here are pull-only, no webhook wakes the backend at the exact moment a carrier outcome changes. Picture one attempt moving through the system: the server requests the code, stores the message identifier, and marks its own record delivery_pending; a scheduled worker later reads the transport result and updates that record; meanwhile, the browser asks only for the application's state and cannot trigger an uncontrolled send. If the result is still pending, the worker waits. If delivery is unsuccessful, the app decides whether the attempt is eligible for an explicit resend or must move to recovery. If the attempt expires first, a later transport result cannot reopen it. This ordering keeps carrier timing from becoming authentication policy, and it also keeps a future transport change away from browser code. Small boundary, large payoff.
Don't resend on every pending result. Two codes arriving out of order create confusion, and an automatic loop can create abuse and cost exposure. Instead, poll with bounded exponential backoff, stop at a deadline chosen for the product, then require an explicit user action. I'm not sure one universal interval works across countries and carriers; completion and abandonment data from the actual product are what would settle that choice.
OWASP's recovery guidance supplies the security floor: return consistent messages, keep response timing consistent, use a side channel, rate-limit requests, make codes single-use, and store them securely. Authentication policy stays local even when transport is managed.
No infinite loops.
Keep it local.
Two criteria matter more than the provider logo
The first is failure ownership. A solo operator needs one place to answer: Is this attempt active? May it be resent? Has it expired? Has it already succeeded? The SMS service supplies transport signals, while the Express app applies attempt limits, account and network controls, expiry, audit rules, and the transition into an authenticated session. Country allowlists and country-pricing circuit breakers also belong in business logic because this stack does not provide built-in geo-fencing or country-based spend breakers.
The second is operational surface area. Every credential stored, dashboard checked, and invoice reconciled competes with feature work. Infrai is a reasonable option when one key and one bill across backend services removes that recurring overhead. That is the useful advantage here, not a claim about being cheapest. The catch is material: SMS status and events are polled rather than pushed, fallback logic remains in the application, and there is no native voice, WhatsApp, or RCS channel.
That trade can still be right. A small scheduled worker is often acceptable when the product only needs SMS and a deliberate alternate login route. It is not suitable when a missed callback window breaks a real-time handoff among SMS, voice, and chat apps. In that case, buy a communications platform designed for immediate omnichannel orchestration.
Existing competence matters too. Stick with Twilio or Vonage when the current integration is monitored, understood, and inexpensive to leave alone. Stick with AWS SNS when the application already lives comfortably inside AWS and its operational model is familiar. A cleaner vendor count on a diagram has no value if the migration displaces the next weekly release.
A runnable TypeScript polling boundary
Keep the service credential on the server. This Express endpoint queries the verified delivery-status route, always supplies the HTTP method, checks the response, and treats 429 as a signal to wait. It honors both forms of Retry-After before falling back to bounded exponential delay.
import express from "express";
const app = express();
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.SMS_API_BASE_URL;
if (!apiKey || !apiBaseUrl) {
throw new Error("INFRAI_API_KEY and SMS_API_BASE_URL are required");
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(retryAfter: string | null, attempt: number): number {
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1_000);
}
const dateDelay = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(dateDelay)) {
return Math.max(0, dateDelay);
}
}
return Math.min(500 * 2 ** attempt, 8_000);
}
async function readDeliveryStatus(messageId: string): Promise<unknown> {
const encodedId = encodeURIComponent(messageId);
const url = new URL(`/v1/sms/status/${encodedId}`, apiBaseUrl);
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await sleep(retryDelay(response.headers.get("retry-after"), attempt));
continue;
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(
`Status request rejected (${response.status}): ${JSON.stringify(body)}`,
);
}
return body;
}
throw new Error("Status request exceeded its retry budget");
}
app.get("/auth/sms-status/:messageId", async (request, response) => {
try {
const status = await readDeliveryStatus(request.params.messageId);
response.json(status);
} catch (error) {
const message = error instanceof Error ? error.message : "Request rejected";
response.status(502).json({ error: message });
}
});
app.listen(3000);
The scheduler should call this boundary only for active attempts and persist a normalized state. The browser reads that state. It never receives the vendor key, and it never decides whether a code may be retried.
Notice what the sample leaves out: it does not guess undocumented response fields. The provider document is unknown until the application validates the schema it actually receives. It also does not retry an OTP creation call; writes require an idempotency strategy so a network retry cannot issue two messages. Keep this example narrow.
When should you choose the runner-up instead?
Choose the incumbent provider when it already has working alerts, runbooks, and a stable server boundary. The runner-up is often the system you know. Don't spend a release swapping transports just to make the architecture look tidy.
Choose an orchestration-focused service when delivery must trigger an immediate cross-channel branch. Pull-only events delay that decision. This stack also does not supply managed email OTP, SMTP relay, voice, WhatsApp, or RCS; an email-code fallback must be built separately, and the pending domestic email vendor cannot serve as evidence for China compliance. Those are capability boundaries, not footnotes.
Scheduled or batched SMS can have a cancellation step, but login normally revolves around resend and verify. There is no tag-aggregated cost-report API, and SMS templates have no list operation, so teams that depend on either capability should account for their own records or select another provider.
For the simple case, the decision stays compact: managed OTP transport, local authentication policy, bounded status polling, and a deliberate fallback. For real-time omnichannel recovery, pick the broader orchestrator. For a stable incumbent integration, leave it alone and ship the feature.
References
- OWASP Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Yahoo Sender Best Practices and Requirements: https://senders.yahooinc.com/best-practices/
Top comments (0)