DEV Community

SaxonFletcher2361
SaxonFletcher2361

Posted on

Reliable SMS OTP Delivery for Beginner 2FA Login in US/EU SaaS

A beginner 2FA login stack for a US/EU SaaS can use SMS OTP, but reliability depends on the queue contract around that login, not on the send call alone. In a gaming contact form, a message goes to the player's regional support queue; in login, one code must reach one device before a deadline. A late delivery is not the same thing as a failed request.

Short answer: choose the SMS OTP API that lets your application own suppression, challenge expiry, and delivery state; treat polling as a bounded observation loop, not as proof that a user is trustworthy. For a beginner US/EU SaaS, that architecture is a better decision rule than chasing the cheapest per-message rate.

That is the whole build log in miniature. Revenue per hour matters. I want to ship weekly, and I outsource undifferentiated message transport, but I do not outsource the login policy that decides who may try again.

No magic.

Region is an explicit data rule

The first useful test is not “can the API send an SMS?” It is “what does the application do after each possible answer?” A provider request can be accepted, rejected by a suppression rule, delivered later, or remain unknown when the OTP expires. Those outcomes need different product behavior.

For a game support queue, I would store an internal challengeId, a normalized destination hash, the country decision, the provider message identifier, and the timestamps for creation, expiry, and last status check. The raw phone number belongs behind the application's access controls. The support queue should receive a stable ticket reference, not a second copy of authentication data. That same record gives login support a useful answer later: the request passed policy, a message was accepted, the code expired, or the application never allowed a send.

The routing rule is deliberately boring: US destinations go to the US queue, EU destinations go to the EU queue, and a destination outside the launch allowlist is rejected before any send attempt. “EU” is an operational label here, not a claim that one sender or one legal policy covers every country. Confirm country and sender requirements before launch.

Suppression comes before the challenge is created. If the destination is blocked, return a generic response and record a policy decision. Do not reveal to an unauthenticated caller that a number is suppressed. That small detail closes an enumeration path and avoids spending effort on a message that the system has already decided not to send.

Should a beginner 2FA login stack use SMS OTP polling?

It should make a finite state transition. Here is the state model I use for the application, independent of a provider's names for its statuses:

Application state Meaning Next action
created A challenge exists and has not been sent Run suppression and policy checks
submitted The transport accepted the send request Check status on a schedule
delivered The message reached the transport's delivered state Let the user submit the code
unknown No terminal delivery answer arrived yet Retry observation until the deadline
expired The code deadline passed Reject the code and stop polling
blocked Local policy prevents delivery or retry Show a generic retry-safe response

The important bit is the deadline. Poll only unresolved messages whose nextCheckAt has arrived. Stop at a terminal state or at OTP expiry. A status worker that runs forever is an accounting leak wearing a reliability costume.

Polling also needs a per-message lock or database claim. Otherwise two web processes can observe the same row and create duplicate work. Backoff should be visible in the record, so an operator can answer “why has this challenge not changed?” without reading worker logs. Imagine a player who taps “send again” while the first message is still submitted, then opens the older code after the second message arrives: if the application has no atomic supersession rule, both codes can look plausible, support sees two message IDs, and a delivery dashboard can report success while the player still cannot log in. The fix is a server-side rule that keeps one active challenge per account and destination, records which challenge superseded which, and makes every verification request check that relationship before accepting a code. I am not sure one cadence will suit every country or traffic shape; a small production sample is what would settle that, not a guess in a README.

A bounded observer in TypeScript

The transport adapter below is intentionally generic. It has no vendor URL and no invented request schema. The adapter receives a status function from the integration layer, while the worker owns expiry, state changes, and the retry schedule. That division keeps a provider test from becoming an authentication rewrite.

type DeliveryState =
  | "created"
  | "submitted"
  | "delivered"
  | "unknown"
  | "expired"
  | "blocked";

type Challenge = {
  id: string;
  state: DeliveryState;
  expiresAt: number;
  nextCheckAt: number;
  attempts: number;
  providerMessageId?: string;
};

type ProviderStatus = "delivered" | "pending" | "rejected";

type StatusReader = (messageId: string) => Promise<ProviderStatus>;

function nextState(
  challenge: Challenge,
  providerState: ProviderStatus,
  now: number,
): Challenge {
  if (now >= challenge.expiresAt) {
    return { ...challenge, state: "expired" };
  }

  if (providerState === "delivered") {
    return { ...challenge, state: "delivered" };
  }

  if (providerState === "rejected") {
    return { ...challenge, state: "blocked" };
  }

  const attempts = challenge.attempts + 1;
  const delayMs = Math.min(60_000, 2_000 * 2 ** Math.min(attempts, 5));
  return {
    ...challenge,
    state: "unknown",
    attempts,
    nextCheckAt: now + delayMs,
  };
}

async function observe(
  challenge: Challenge,
  readStatus: StatusReader,
  now = Date.now(),
): Promise<Challenge> {
  if (!challenge.providerMessageId || now < challenge.nextCheckAt) {
    return challenge;
  }

  const providerState = await readStatus(challenge.providerMessageId);
  return nextState(challenge, providerState, now);
}
Enter fullscreen mode Exit fullscreen mode

The real adapter still needs explicit timeout handling, authentication, and response validation. A timeout should preserve an unresolved application state and schedule a later observation; it should not silently turn into “delivered.” Also keep the provider message ID out of a browser-controlled request. The browser can present a challenge ID and a code. The server decides which record that ID maps to.

The verification endpoint has its own guardrails: compare the submitted code on the server, enforce an attempt limit, expire the challenge, and make successful verification single-use. Delivery status is a hint for the UI and support tooling. It is not an authentication factor.

The worksheet I use before launch

How do suppression and status polling change the cheapest stack decision? They change the unit of comparison. “Price per SMS” is only one line in the cost model. Add engineering time for suppression checks, queue ownership, status retention, regional sender setup, abuse limits, and support diagnosis. A provider with a familiar SDK may be the right choice when the team already operates it. A plain HTTP integration may be better when shipping a small service in several languages matters. Your mileage may vary because delivery is a property of destination, sender configuration, and traffic, not a universal product score.

For a beginner build, I would compare candidates with the same worksheet:

  • Can the system create and verify a challenge without the application generating a second, conflicting code?
  • Can suppression be checked before send, with a generic response to the caller?
  • Are delivery states documented well enough to map into delivered, unknown, and terminal failure?
  • Can the application set a deadline and stop polling cleanly?
  • Are country, sender, retention, and abuse controls explicit rather than hidden in a demo?
  • Can the integration be replaced behind one adapter if a US or EU delivery test changes the decision?

Run that worksheet with representative destinations. A tiny test with one phone in one country proves almost nothing.

What I would change at scale

At higher volume, move the worker to a queue with leases, metrics, and a dead-letter path for records that need an operator. Emit counters for challenge creation, suppression decisions, verification success, expiry, and unknown-at-deadline. Keep them separate by country and support queue. This shows where reliability is being lost without turning a delivery metric into a security claim.

Add circuit breakers for spend and abuse at the application boundary. Rate-limit by account, device, destination, and network signal, with care not to make a shared household impossible to use. Store enough event history to investigate a support ticket, but set a retention rule for phone and authentication data.

The catch is that this design is not suitable when the product requires a non-SMS factor, immediate push events, or a provider-specific channel with a different verification model. Choose the channel and event contract first in that case. Stick with an incumbent when it already meets those requirements and a migration cannot improve delivery reliability or developer time.

For a one-person SaaS, the winning stack is the one that makes a correct state transition easy to test. Ship the boring version. Measure it. Then change the adapter, not the login rules.

References

Top comments (0)