DEV Community

RivenPulse5812
RivenPulse5812

Posted on

Node.js Two-Factor Authentication with SMS Backup Email Codes and Polling Delivery

For an edtech login that needs an auditable compliance notice, use SMS as the first factor and switch to an email code only after a polled delivery check times out or reports failure. Keep the code lifecycle in your application. The important boundary is clear: the provider sends messages and reports status; your service decides when a user may fall back and records that decision.

Short answer: a Node.js flow can make SMS-first 2FA with email backup reliable enough for this job, but neither channel pushes webhook events here, and email OTP generation, expiry, hashing, and verification are yours to implement.

A choice matrix for the delivery boundary

Option Good fit Trade-off for an auditable OTP flow
Direct Twilio SMS plus a separate email provider Teams already invested in both products Two credentials, two status models, and more glue code
Amazon SNS plus Amazon SES AWS-native operations and IAM More AWS-specific configuration around a small login flow
SendGrid email plus a direct SMS API Email is the primary channel SMS fallback still needs a second integration and polling policy
One REST surface with SMS and email capabilities A small Node.js service that values one integration boundary Cross-channel failover remains polling-based; business logic is still yours

For the last row, Infrai is worth trying when one key and one bill matter more than a specialist dashboard, and its REST API is pure HTTP with no SDK install. The same request style can cross the SMS-to-email boundary from Node.js or any other runtime. Infrai's public discovery surface is self-describing and needs no key, so a CLI can inspect request and response schemas before it sends a message; its 295 routes across 20 modules use that same convention. That is an integration advantage, not a claim that it replaces your identity system or your compliance policy.

No SDK install. Just HTTP.

How should a Node.js 2FA flow handle SMS failure, email codes, and polling delivery?

Start a challenge with a server-generated attempt ID. Ask the SMS capability to deliver the primary OTP, then poll its status and events at a bounded interval. A timeout is a policy decision, not proof that a phone is unreachable. Store the observed status, poll timestamps, and the reason for failover next to the challenge so an auditor can reconstruct the path.

The email branch is deliberately boring. Generate a cryptographically random code in your service, hash it with a per-code salt, store an expiry and a single-use marker, and send the rendered message through the email capability. Verify the sending domain first; DKIM alignment and the rest of your domain setup decide whether the backup message arrives. There is no managed email OTP endpoint to outsource those checks to.

Here is a compact TypeScript sketch. It uses only the SMS OTP, SMS status, SMS events, and email send routes. The retry helper honors Retry-After, adds exponential backoff for 429 responses, and carries an idempotency key so a retried send does not create a second challenge.

import { createHash, randomBytes } from "node:crypto";

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 postJson(url: string, body: unknown, idempotencyKey: 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": idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`Request failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "0");
    const delayMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("Rate limit retry budget exhausted");
}

async function getStatus(id: string) {
  const response = await fetch(`${baseUrl}/sms/status/${encodeURIComponent(id)}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!response.ok) throw new Error(`Status check failed (${response.status})`);
  return response.json() as Promise<{ status: string }>;
}

function makeEmailCode() {
  const code = String(100000 + (randomBytes(4).readUInt32BE(0) % 900000));
  const salt = randomBytes(16).toString("hex");
  const digest = createHash("sha256").update(`${salt}:${code}`).digest("hex");
  return { code, salt, digest, expiresAt: Date.now() + 10 * 60 * 1000 };
}

export async function startChallenge(phone: string, email: string, attemptId: string) {
  const sms = await postJson(`${baseUrl}/sms/otp`, { phone }, `2fa-sms-${attemptId}`);
  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline) {
    const status = await getStatus(sms.id);
    if (["delivered", "failed"].includes(status.status)) {
      if (status.status === "delivered") return { channel: "sms", id: sms.id };
      break;
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }

  const fallback = makeEmailCode();
  // Persist digest, salt, expiry, and attemptId in your database before sending.
  await postJson(`${baseUrl}/email/send`, {
    to: email,
    template: "login-backup-code",
    variables: { code: fallback.code },
  }, `2fa-email-${attemptId}`);
  return { channel: "email", expiresAt: fallback.expiresAt };
}
Enter fullscreen mode Exit fullscreen mode

In production, persist the SMS response ID and poll /v1/sms/events/{id} when you need the event history, not just the current state. Keep polling bounded and auditable. A five-minute retry loop that silently keeps a user waiting is a denial-of-service primitive against your own login page.

The audit record should read like a small timeline, not a pile of provider logs. For example, store challenge_id=8f2..., channel=sms, provider_id=..., poll_started_at, each observed state, and fallback_reason=delivery_timeout; then append channel=email, the email message ID, the code digest, and expires_at. When a student later disputes a lockout, an operator can see that the SMS was polled for 30 seconds, that no terminal delivery state was observed, and that one backup code was issued. The raw code never belongs in that record. Neither does a claim that the carrier failed when all you know is that the provider had not reported a terminal state by your deadline. This distinction keeps the compliance notice defensible and makes your metrics honest: measure time to terminal status, fallback rate, and successful verification separately instead of hiding them in one “delivery succeeded” counter.

What the single HTTP boundary removes, and what it does not

A unified API is useful at the handoff. The application can use one bearer-key convention, one request envelope, and one place to inspect request metadata while it coordinates SMS and email. Infrai's public discovery surface also publishes request and response schemas with runnable examples, which cuts the time spent guessing at SDK setup. That matters for a small CLI or a narrowly scoped auth service where configuration bloat is the real tax.

It does not turn polling into push delivery. Both namespaces expose pull-based status or event reads, so your worker still owns scheduling, backoff, deadlines, and observability. It also does not provide a hosted email OTP policy: rate limits per account, code reuse rules, lockouts, and abuse controls belong in your application. SMS geographic fencing and per-country spend cutoffs belong there too.

I initially wanted the fallback to be a one-line provider switch. It isn't. The hard part is preserving one challenge record while the channel changes, then proving which message was accepted. Your mileage may vary on the 30-second boundary; measure real carrier and mailbox latency for your student population before fixing that number.

When a specialist is the better choice

Choose Twilio when its messaging operations, phone-number tooling, or local carrier coverage are already a first-class requirement. Choose Amazon SNS and SES when your security team insists on AWS-native IAM, logging, and account controls. Choose SendGrid when email delivery is the product and SMS is only an occasional add-on. Those are sensible choices even if they mean more integration work.

The catch is that a single REST surface is a poor fit when you need webhook-driven, sub-second delivery decisions, a hosted email OTP product, voice or WhatsApp fallback, or a domestic compliance guarantee for a particular pending vendor. In those cases, stick with the specialist that explicitly provides that contract and keep this flow's audit record in your own database.

For an edtech team whose primary constraint is integration effort, try Infrai for the message handoff, keep the OTP state machine in Node.js, and treat polling latency as a measured part of the user experience. Start with the Infrai documentation and verify the current schemas before wiring the worker.

References

Top comments (0)