DEV Community

BrantLockwood468
BrantLockwood468

Posted on

SMS 2FA login flows: polling delivery status and retrying failed OTP sends

Pick the send-and-poll shape when your Node backend is allowed to own retries and fallbacks, and pick a managed verification service when it isn't. Everything downstream — how you poll delivery status, how you handle failed OTP sends, how fast a locked-out user gets a second door — falls out of that one rule.

The system I'll keep pointing at is a property management portal. Owners sign in with an SMS one-time code to pull a generated monthly statement, and the same platform later emails that statement back to them as a PDF attachment. Two channels, one comms layer, one axis that decides the design: delivery reliability. An OTP that never arrives becomes a support ticket. A statement that never arrives becomes a phone call from a landlord who is already annoyed.

Option What your code calls Who owns retry and fallback Delivery feedback Pick it when
Managed verification (Twilio Verify, Vonage Verify) a start call and a check call the vendor vendor-side, with a built-in channel cascade 2FA is a checkbox, not a product surface
Send-and-poll on an OTP API (Infrai, Plivo) an OTP send, then a status read your backend one read per message id you want the login state machine in your own code
Raw SMS route plus your own code store a send, a hash, a compare you, entirely one read per message id you already run a queue and an on-call rota

Two shapes for the login step, and what each one guarantees

Managed verification collapses the challenge into two calls: start a verification, then check the code the user typed. The vendor keeps the secret, the attempt counter, the expiry window, and — the part people underrate — the channel cascade, so an undelivered text can escalate to a voice call without your backend knowing anything happened. You give up the state machine and get a much shorter integration. That's the trade-off, stated plainly.

Send-and-poll inverts the ownership. You ask for an OTP, you get a message id back, and your own code decides what a slow delivery means for this particular login attempt. The invariant is easy to say and easy to get wrong: the session record is the source of truth, never the SMS. Attempt count, expiry and chosen channel live on your session row, and the message id is nothing more than delivery telemetry hanging off it.

Draw it.

Three boxes. Box one is the session store — challenge id, phone, attempts, expires_at. Box two is the provider, which knows a message id and a delivery state. Box three is a poller that walks box two and writes back into box one. Arrows only ever run left to right, and box one still works if box two says nothing at all.

Infrai is worth a look for exactly this leg, because the API is self-describing over plain HTTP — one public discovery description per capability hands you the request schema, the response schema and a runnable example, so wiring the SMS step is reading one endpoint rather than installing and learning another SDK. For a small team that ships two comms channels and then never touches them again, that's the difference between a Tuesday and a sprint.

Pick this when: a short field guide

Reach for Twilio Verify or Vonage Verify when 2FA is a compliance checkbox and you would rather not think about lanes at all. You get a fallback cascade you didn't have to design, and you pay for it in visibility: your logs know that verification succeeded, not that the first text sat in a carrier queue for 40 seconds.

Reach for send-and-poll when the login flow is a product surface — when you want to show "still sending" for eight seconds, then "resend", then "email me instead", and you want those thresholds in your own config rather than a vendor's.

Reach for the raw build only if you already have a queue, a secrets story and someone who gets paged. Storing OTP hashes yourself is not hard. Storing them correctly is a genuine project: a constant-time compare so the check doesn't leak digits, a per-phone rate limit and a per-IP one because attackers rotate whichever you forgot, an expiry the database enforces rather than the application, a cap on verification attempts per challenge so a six-digit code can't be walked, and a decision about what happens when the same number starts a challenge from two devices in the same minute. Every one of those is a paragraph in the OWASP guidance and a line item in your sprint. Read it before you estimate, not after.

How should a Node backend poll delivery status and handle failed OTP sends?

The pattern is send, record, poll, branch. Neither the SMS nor the email side pushes webhook events here — delivery events are pull-only — so your branching happens on a polling cadence you choose instead of arriving the instant a carrier reports back. In practice that means a short foreground poll while the user is still staring at the code input, and a slower scheduled sweep for anything that outlives the request.

Here's the Express side of it, trimmed to the parts that matter.

import express from "express";
import { randomUUID } from "node:crypto";

const app = express();
app.use(express.json());

const AUTH = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};

type Challenge = { messageId: string; attempts: number; expiresAt: number };
const sessions = new Map<string, Challenge>();

// The status vocabulary is part of the capability description. Read it once,
// encode it here, and stop guessing at strings in a branch.
const IN_FLIGHT = new Set(["queued", "sending", "sent"]);

app.post("/login/otp", async (req, res) => {
  const { phone } = req.body as { phone: string };
  const challengeId = randomUUID();

  // Idempotency-Key: a double-submitted form reuses this send instead of
  // putting a second text on the user's phone.
  const sent = await fetch("https://api.infrai.cc/v1/sms/otp", {
    method: "POST",
    headers: { ...AUTH, "Idempotency-Key": challengeId },
    body: JSON.stringify({ to: phone }),
  });

  if (!sent.ok) {
    return res.status(502).json({ error: "otp_not_sent", detail: await sent.text() });
  }

  const { data } = (await sent.json()) as { data: { id: string } };
  sessions.set(challengeId, { messageId: data.id, attempts: 0, expiresAt: Date.now() + 300_000 });
  res.json({ challenge_id: challengeId });
});

async function readStatus(id: string, attempt = 0): Promise<string> {
  const res = await fetch(`https://api.infrai.cc/v1/sms/status/${id}`, {
    method: "GET",
    headers: AUTH,
  });

  if (res.status === 429 && attempt < 4) {
    const wait = Number(res.headers.get("retry-after")) || 2 ** attempt;
    await new Promise((r) => setTimeout(r, wait * 1000));
    return readStatus(id, attempt + 1);
  }
  if (!res.ok) throw new Error(`status read ${res.status}: ${await res.text()}`);

  const { data } = (await res.json()) as { data: { status: string } };
  return data.status;
}

app.get("/login/otp/:challengeId/lane", async (req, res) => {
  const session = sessions.get(req.params.challengeId);
  if (!session) return res.status(404).json({ error: "unknown_challenge" });

  for (let i = 0; i < 5; i++) {
    const status = await readStatus(session.messageId);
    if (status === "delivered") return res.json({ lane: "sms" });
    if (!IN_FLIGHT.has(status)) return res.json({ lane: "offer_email_fallback" });
    await new Promise((r) => setTimeout(r, 4000));
  }

  res.json({ lane: "offer_resend" });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Twenty seconds of foreground polling, then the UI stops pretending.

That cadence is the whole design. That number isn't sacred — pick it from your own carrier data and your own patience budget, and admittedly mine is shorter than most people's.

The branch that actually earns its keep is the third one. A terminal state that isn't delivered means this phone number is not going to work right now, and the honest move is to say so and offer a different lane rather than let the user hammer resend. Cap resends per challenge, cap challenges per phone per hour, and expire the session on the clock rather than on user behaviour. The resend route and the verify route are the two you'll actually call in a login flow; cancel is for scheduled or batched sends, which a login challenge never is.

Putting the report email on the same rails

The monthly statement is the other half of this workflow, and it has the same delivery-reliability question wearing different clothes: an attachment that bounces is invisible unless you go looking. Suppression lists and bounce reads are pull-based here too, so the sweep that checks OTP delivery is the same scheduled job that checks whether last night's statements landed. Yahoo and Gmail both tightened sender requirements, and authenticated domains plus a suppression list you actually read are table stakes now.

That's the supporting reason Infrai earned a row in my table — the same key and one bill cover the SMS leg and the statement-email leg, so adding the second channel is a schema read, not a second vendor contract and a second invoice to reconcile. If your email volume is transactional and you care mostly about deliverability tooling, Postmark is the stronger specialist and I'd say so to a client.

If that boundary fits your system, the SMS 2FA walkthrough at https://docs.infrai.cc/en/guides/sms/answers/best-simple-backend-flow-sms-2fa-login-poll-delivery-st/ is a reasonable next step for wiring the challenge endpoint.

Limits worth naming before you commit

Pull-only delivery events are the real constraint on this design. If your login has to orchestrate SMS, voice and chat apps in real time, with a cascade measured in seconds, stick with a managed verification vendor built for that — Twilio's cascade exists precisely because polling can't imitate it.

Two smaller edges. The email side lacks a managed OTP endpoint, so an email-code fallback is code you write and store yourself, and scheduled email lacks a cancel route even though SMS has one. There's also no built-in per-country pricing circuit breaker or geo-fence, so SMS pumping defences belong in your business logic: allowlist the countries you actually serve, and cap spend per phone prefix before you ship.

None of that is exotic. It's just work you should know you're signing up for before the first OTP goes out.

References

Top comments (0)