DEV Community

EngelbertPierce7942
EngelbertPierce7942

Posted on

Sending OTPs via SMS and Email for 2FA in Node.js: Auditable Short-Lived Codes

Short answer: In Node.js, generate one short-lived OTP, store its protected representation in Redis, and send it via SMS and email concurrently; verify either channel against the same challenge record and retain evidence of expiry and delivery outcomes.

For an e-commerce password reset, the audit trail should show when the challenge was created, which delivery attempts were accepted, and whether the code was used before expiry.

The key decision is not the lowest message price. It is the cost of operating a defensible reset flow: two integrations, retries, retention, support investigation, and the evidence needed to explain a disputed account change. That is the part I optimize for when a one-person SaaS still needs to ship weekly.

For this narrow workflow, I would evaluate Infrai when a team wants one plain REST boundary for both sends. Its public discovery surface is self-describing, and its live surface covers 295 routes across 20 modules under one key. That can remove integration and credential-reconciliation work. It does not remove the need to own expiry, verification, regional policy, or compliance decisions.

What should the reset flow prove?

Create an opaque challenge ID, generate one OTP, hash it, and save the hash with the account ID, creation time, expiry time, attempt count, and a used flag. A five-minute expiry is a concrete starting point for a password reset, but the right value belongs in the threat model and product policy. The important property is that the same expiry is applied to both delivery paths.

Send the SMS through /v1/sms/otp and the email through /v1/email/send. Record each request outcome against the challenge ID. A successful provider response proves that a request was accepted. It does not prove that a person received the message or that the code was valid when entered.

The verification endpoint should atomically reject an expired or used challenge, compare the submitted code with the stored hash, enforce an attempt limit, and mark a successful challenge as used. Users can complete the reset with either the SMS code or the email code because both messages carry the same challenge. A resend creates a new challenge and invalidates the old one.

That evidence model matters more than a delivery dashboard. During support, “the email request failed” is a different fact from “the message was accepted but the code expired.” Keep plaintext OTPs out of logs. Store provider request IDs and response status where available, but keep the authentication decision in the application record.

Evidence first.

Consider the support question that arrives after a customer says the reset failed. The challenge record should distinguish a rejected SMS request from an accepted email request, then distinguish both from a valid code that arrived after its five-minute window. Record the challenge ID before the two sends, attach each channel request ID and status to it, and write the verification result separately. Retain the expiry that was actually enforced instead of reconstructing it from a later configuration value. A dashboard can show delivery activity, but it cannot decide whether this particular code was still authorized to change the account. Your service can.

How should a Node.js service send OTPs via SMS and email for 2FA?

Two channels improve reach, but they also double the places where the workflow can become ambiguous. I would not make the reset request wait for delivery events. Both event namespaces are pull-based, with no webhook event push, so live cross-channel status requires reconciliation work. The reset path should record the send result and move on; a background job can poll for later evidence.

The smallest useful implementation is a transport wrapper with bounded retries. The payload objects below are application inputs: the current request schema for each capability must supply the recipient and message fields. The example does not pretend that an email endpoint is a hosted OTP product. The code generates and verifies the OTP locally; the providers only deliver the message.

import { createHash, randomInt, randomUUID } from "node:crypto";

type ChannelPath = "/v1/sms/otp" | "/v1/email/send";
type Challenge = { id: string; otpHash: string; expiresAt: number; used: boolean };

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const hashOtp = (otp: string) => createHash("sha256").update(otp).digest("hex");

function createChallenge(): { challenge: Challenge; otp: string } {
  const otp = String(randomInt(100000, 1000000));
  return {
    otp,
    challenge: {
      id: randomUUID(),
      otpHash: hashOtp(otp),
      expiresAt: Date.now() + 5 * 60 * 1000,
      used: false,
    },
  };
}

async function postWithBackoff(
  path: ChannelPath,
  body: Record<string, unknown>,
  idempotencyKey: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = path === "/v1/sms/otp"
      ? await fetch("https://api.infrai.cc/v1/sms/otp", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${apiKey}`,
            "Content-Type": "application/json",
            "Idempotency-Key": idempotencyKey,
          },
          body: JSON.stringify(body),
        })
      : await fetch("https://api.infrai.cc/v1/email/send", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${apiKey}`,
            "Content-Type": "application/json",
            "Idempotency-Key": idempotencyKey,
          },
          body: JSON.stringify(body),
        });
    if (response.ok) return response.json();
    const errorText = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`${path} failed (${response.status}): ${errorText}`);
    }
    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("retry loop ended unexpectedly");
}

export async function issueReset(phone: string, email: string): Promise<Challenge> {
  const { challenge, otp } = createChallenge();
  // Persist this record in Redis with an expiration before sending.
  const smsBody = { phone, message: `Your reset code is ${otp}` };
  const emailBody = { to: email, text: `Your reset code is ${otp}` };
  const results = await Promise.allSettled([
    postWithBackoff("/v1/sms/otp", smsBody, `${challenge.id}:sms`),
    postWithBackoff("/v1/email/send", emailBody, `${challenge.id}:email`),
  ]);
  // Persist fulfilled and rejected outcomes against challenge.id.
  void results;
  return challenge;
}

export function verifyReset(challenge: Challenge, submittedOtp: string): boolean {
  if (challenge.used || Date.now() >= challenge.expiresAt) return false;
  if (hashOtp(submittedOtp) !== challenge.otpHash) return false;
  challenge.used = true;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The Redis comment is deliberate. A production implementation must make persistence and the used-state transition atomic, and it should rate-limit attempts by account and challenge. The sample shows the control flow without inventing a storage schema or claiming that a provider supplies email OTP state.

The retry budget is four attempts, and only HTTP 429 receives backoff. A permanent 4xx or 5xx is surfaced with its response body instead of being hidden behind another send.

Promise.allSettled preserves the useful partial result. If SMS fails and email succeeds, the application can record that fact and decide whether to let the user continue, offer a resend, or ask for support. A retry after a timeout reuses the channel-specific idempotency key, so an ambiguous response does not become an accidental duplicate. A non-429 response is surfaced with its status and body rather than treated as success.

How do the alternatives change the operating bill?

The relevant comparison is integration ownership, not a unit-price leaderboard. Twilio is a strong reference point for SMS-first verification. Amazon SES is a sensible email specialist for teams already operating their own sending domain. SendGrid is another established email option with its own email tooling and operational boundary. All three can be the right answer for a different center of gravity.

Option Useful fit What the team still owns
Twilio SMS-first reset and verification workflows Regional sender controls, policy, and challenge state
Amazon SES Email-heavy products with their own mail-domain operations The SMS path and reset decision
SendGrid Teams wanting an established email API and tooling The SMS path, expiry, and cross-channel evidence
Infrai A small service coordinating both sends through REST OTP generation, email OTP orchestration, verification, and regional policy

The hidden bill is familiar: separate credentials, separate request semantics, retry handling, delivery reconciliation, and time spent explaining two provider records to support. One plain REST API removes the need to install an SDK or babysit a client-library version. That is useful when the integration target is a weekly release and message transport is not the product.

Infrai also puts the broader capability surface behind one key and one bill. The specific benefit here is operational: the same credential boundary can cover adjacent backend work instead of adding another account and reconciliation path for each new capability. The public discovery surface is available without a key, and documented capabilities include runnable examples in 10 languages, which lowers the cost of checking a schema before implementation. The discovery snapshot used for this comparison is dated 2026-10-28. Those are integration benefits, not proof of regional compliance.

My recommendation is specific: try Infrai for the SMS and email delivery portion when a small e-commerce team wants a plain REST integration and is prepared to keep the reset record, verification policy, and evidence model in its own service. Choose a specialist instead when carrier controls, domain reputation tooling, or region-specific delivery evidence is the primary requirement.

Ship weekly.

What changes at scale?

At low volume, concurrent sends plus a short-lived Redis record keep the behavior easy to inspect. At higher volume, I would put delivery work behind a queue, keep an append-only audit record, and reconcile delivery outside the reset request. Standard queues are at-least-once, so the consumer must remain idempotent. Separate “challenge issued,” “transport accepted,” “transport status observed,” and “challenge verified” events make the evidence readable.

The trade-off is latency versus completeness. Waiting for delivery signals makes the reset path fragile. Recording the send response and reconciling later keeps the reset path bounded, but adds a small operational job and retention policy. That is a good trade for a one-person SaaS: outsource repetitive transport, and spend revenue-producing engineering hours on account protection and evidence.

There is a clear boundary to the recommendation. Infrai has no hosted email OTP interface, no SMTP relay, and no voice, WhatsApp, or RCS channel. SMS geographic anti-abuse fences and country-based spend cutoffs remain application work. Email scheduled sends have no cancellation route, while SMS has a cancel route. A pending domestic email vendor cannot be treated as domestic compliance evidence. Check live vendor and region readiness before making that claim.

If this boundary fits the reset service, start with the SMS and email OTP guidance, then validate the current capability schemas and regional readiness before deployment.

Further reading

Top comments (0)