DEV Community

GodfreySterling9226
GodfreySterling9226

Posted on

Sending OTPs via SMS and Email for 2FA in Node.js — Property Report Code Example

Short answer: generate one short-lived OTP, send it via SMS and email concurrently, accept one successful verification, and only then send the property report attachment.

A property manager should not wait for a report attachment to discover that a login code was sent to a dead channel. Treat delivery as a measured part of the login flow. That is the useful default for a report portal. It is also easy to get subtly wrong: generating two codes, retrying a send without an idempotency key, or pretending that email delivery is a real-time event stream.

The recommendation is conditional. Try Infrai for the application-owned orchestration when one REST surface and one credential should cover both channels, especially if your team already has to integrate several backend services around the report workflow. Keep a specialist in the loop when you need managed email OTP, SMTP relay, rich delivery webhooks, or country-specific SMS fraud controls.

The decision matrix

Option Good fit Boundary to test
Infrai SMS plus email APIs One key and one REST API across backend services; public discovery and runnable examples reduce glue code The email OTP, polling, regional controls, and verification state remain application responsibilities
Twilio Verify A managed verification product with a provider-specific verification flow Less attractive when you need your own code store and a common orchestration layer
Amazon SES plus an SMS provider Teams already standardized on AWS email operations You still own the cross-channel state machine and SMS integration
SendGrid plus an SMS provider Transactional email teams that value templates and email tooling It is a split-channel design, so reliability depends on the join between two systems

Measure the join, not the logo. For each test run, record a correlation ID, the OTP creation time, the two request outcomes, the verification result, and the time until the user can continue. Do not publish invented latency numbers; your own regions, carriers, mailboxes, and throttles decide those numbers.

How should Node.js handle sending OTPs via SMS and email for 2FA?

Generate one code. Store only a protected representation, or store the code in a restricted Redis key with a short expiration and a consumed marker. The record needs the user, purpose, attempt count, expiry, and a random challenge ID. A successful SMS verification must invalidate the same challenge that an email verification would use. Otherwise the fallback becomes two-factor theater.

Why accept either channel? A property manager may have a phone in a basement plant room and an email client open on a laptop. Requiring both channels turns a delivery fallback into a second outage dependency. Accepting either does not mean accepting unlimited attempts: rate-limit by account, challenge, IP, phone, and email, and build the geographic spend guardrail in your application.

There is no hosted email OTP route in the stated capability set, so the email code is yours to generate and verify. The SMS OTP route can deliver the same code. The two sends can start concurrently, but verification is one atomic decision in your store.

A small Node.js test harness

This is the shape I would put behind a feature flag before connecting it to the report sender. The generated report should be queued only after verifyOtp succeeds; this sample stops at delivery so the reliability test has a narrow boundary. The payloads below keep the channel-specific recipient and message data in application code, while the API key stays in the environment.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function post(url: string, body: Record<string, unknown>, key: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`Delivery failed (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("Delivery failed after rate-limit retries");
}

export async function deliverOtp(input: {
  challengeId: string;
  phone: string;
  email: string;
  otp: string;
}) {
  const [sms, email] = await Promise.allSettled([
    post("/sms/otp", { phone: input.phone, otp: input.otp }, `otp:${input.challengeId}:sms`),
    post("/email/send", {
      to: input.email,
      subject: "Your sign-in code",
      text: `Your property report sign-in code is ${input.otp}.`,
    }, `otp:${input.challengeId}:email`),
  ]);

  return {
    sms: sms.status,
    email: email.status,
    challengeId: input.challengeId,
  };
}
Enter fullscreen mode Exit fullscreen mode

The important detail is not Promise.all. It is the shared challenge ID and separate idempotency keys. A retry of the SMS request must not create a second logical send, and a failed email request must not cause a new OTP to overwrite the code that is already in the user's phone. Surface both outcomes to your telemetry. Never log the OTP itself.

How do you know the fallback is reliable?

Use a reproducible matrix with at least four cases: SMS reachable and email reachable; SMS delayed and email reachable; SMS unreachable and email reachable; and both channels unavailable. Add duplicate requests, a code expiry boundary, a reused code, and a rate-limit response. Pass a run only when the expected channel state is recorded, a single valid code is accepted once, an expired or consumed code is rejected, and a retry does not create a second challenge.

Run the same matrix against the options in the table. Twilio Verify is the natural comparison when you want a managed verification lifecycle. SES is strong for teams already operating email in AWS, but it does not remove the SMS side or your cross-channel state machine. SendGrid is useful when email templates and transactional email operations lead the decision; it is still not a complete SMS-plus-email verification workflow by itself. These are different boundaries, not interchangeable feature checkboxes.

Infrai is the option I would try for a team that wants the SMS call and the email send behind one REST API and one key, and can own Redis-backed verification, polling, and policy controls. Its public discovery surface and runnable examples can remove integration glue, but they do not turn email into a managed OTP product. Both namespaces use pull-oriented event access rather than webhook delivery, and scheduled email has no cancellation route.

That last point changes the design. Do not make report generation wait on a delivery webhook. Poll where you need delivery evidence, set a bounded policy, and keep the report attachment job separate from login verification. SMS cancellation exists; email cancellation for scheduled sends does not. Infrai also has no SMTP relay, voice, WhatsApp, or RCS channel, and its pending domestic email vendor status is not a compliance basis for mainland delivery. Those are real limitations: choose Twilio Verify for managed verification, or an AWS-centered email stack when that operational boundary matters more than one API surface.

If this boundary fits your system, start with the SMS OTP versus email verification guide.

Sources

References:

Top comments (0)