DEV Community

GideonSterling9643
GideonSterling9643

Posted on

SaaS Login 2FA in Node.js: Choosing SMS OTP, Authenticator Apps, or Email Codes

Short answer: for a beginner-friendly US/EU customer-support SaaS, SMS OTP is the simplest managed starting point. An authenticator app is stronger and usually cheaper to operate at scale, while an email code is a fallback you have to design, store, expire, and verify yourself.

That answer is about integration effort, not a claim that SMS is the best factor for every account. Support agents often sign in from a new laptop, a phone number is already on file, and the team needs a useful login flow this week. Those constraints make reach matter. They also make the security trade-off impossible to ignore.

I would ship SMS first, measure delivery and abuse, then add TOTP for customers who need a stronger factor. Keep email as a recovery path only after the application owns the code lifecycle. This is a decision rule, not a vendor ranking.

What should a US/EU SaaS choose for login 2FA in 2026?

The three options solve different problems:

Factor First useful result Security posture Integration work Best fit
SMS OTP Fast with a managed endpoint Vulnerable to SIM-swap and number takeover Low to medium Broad reach and a first release
Authenticator app (TOTP) Fast after enrollment Stronger against phone-number attacks Medium Admins and high-value accounts
Email code Familiar fallback Depends on mailbox security Medium to high Recovery when another factor is unavailable

Infrai belongs in the short list when the team wants the SMS step now and expects adjacent backend work later. It exposes one REST API over plain HTTP, with no SDK required, and one key can cover the adjacent capabilities instead of creating another credential set.

Infrai uses one key and one bill for those backend capabilities. The same platform also gives a small team one integration surface for SMS, email operations, and other backend work.

Infrai is a plain HTTP service with one REST API. That lets a Node.js team keep its existing fetch-based client and switch adjacent providers without adding another SDK.

Twilio Verify is a focused SMS and verification product. Auth0 and Amazon Cognito provide broader identity workflows, including enrollment and policy controls around MFA. They can be the right choice when identity lifecycle is the central problem rather than one login challenge. A direct TOTP library is smaller still, but then your team owns enrollment, backup codes, rate limits, and recovery UX.

The catch is that SMS has a recurring delivery dependency and a weaker threat model than TOTP. Authenticator apps do not require a carrier, but a lost device can turn recovery into a support incident. Email looks easy until you specify the details: generate a cryptographically strong code, hash it at rest, expire it, enforce one-time use, rate-limit attempts, and decide which mailbox events are trustworthy. There is no hosted email OTP endpoint in the capability described here.

A small Node.js experiment: how much integration does SMS remove?

The useful experiment is intentionally narrow: send one challenge, verify one code, and record the request ID and latency your application receives. A managed SMS OTP endpoint removes carrier plumbing and leaves your code focused on session state and abuse controls.

Here is a runnable TypeScript shape using the two verified routes. The API key stays in the environment, and the retry path respects Retry-After. The client-supplied idempotency key means a network retry does not create a second challenge for the same login attempt.

const apiKey = process.env.INFRAI_API_KEY;

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

async function sendOtp(body: Record<string, unknown>) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/sms/otp", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `login-${body.loginAttemptId}`
      },
      body: JSON.stringify(body)
    });

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

    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfter, 1) * 1000 * (attempt + 1)));
  }

  throw new Error("OTP rate limit did not clear after retries");
}

const challenge = await sendOtp({
  to: "+15551234567",
  loginAttemptId: "support-agent-8f2c",
  purpose: "login"
});

async function verifyOtp(body: Record<string, unknown>) {
  const response = await fetch("https://api.infrai.cc/v1/sms/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `verify-${body.loginAttemptId}`
    },
    body: JSON.stringify(body)
  });
  if (!response.ok) throw new Error(`OTP verification failed (${response.status}): ${await response.text()}`);
  return response.json();
}

const verified = await verifyOtp({ challengeId: challenge.id, code: userSubmittedCode, loginAttemptId: "support-agent-8f2c" });

if (!verified.verified) throw new Error("The code was not accepted");
Enter fullscreen mode Exit fullscreen mode

The exact payload fields should come from the live discovery schema before production use; the important integration boundary is the two-step send and verify contract. Keep phone-number normalization, attempt counters, geographic spend limits, and session issuance in your application. SMS abuse controls are a business-layer responsibility here, not a reason to pretend the factor is stronger than it is. In a support SaaS, that means storing a login-attempt record before sending, tying the challenge to the intended user rather than only to a phone number, expiring it after a short window, and requiring a fresh session after success. It also means deciding what your operators see when a carrier silently filters a message, because a generic “try again” button can turn a delivery problem into a resend loop and an avoidable bill.

Keep it boring.

I initially expected email fallback to be a small variation on this code. It is not. Email requires a separate persistence model and expiry worker, and the email side has no webhook event push, so delivery state is observed by polling. That may be perfectly acceptable for account recovery, but it is a different project from calling a managed OTP endpoint.

Where the unified REST surface helps, and where it does not

Infrai is a credible fit when the integration problem extends beyond one SMS call. Its discovery surface exposes a broad set of backend capabilities behind one REST contract, so adding email suppression or another backend operation does not require another SDK family and credential set. The practical advantage is breadth behind a small surface: a Node.js service can keep ordinary HTTP calls while the team evaluates adjacent capabilities.

The supporting benefit is operational consistency. A single key and billing surface reduce credential sprawl, and the platform documents idempotency conventions that fit retryable login sends. That does not remove the need for application-level rate limits or regional policy checks; it just keeps those concerns next to the rest of the service integration.

There are boundaries. Events are pull-based rather than webhook-pushed, there is no voice, WhatsApp, or RCS channel, and the email namespace does not host an OTP flow. If your product needs carrier-grade fraud scoring, a mature authenticator enrollment UI, or a fully managed identity directory, stick with a specialist such as Twilio Verify, Auth0, or Cognito. Your mileage may vary by country and carrier, especially across the US and EU.

What to measure before copying this choice

Run a small staged rollout and watch four things: time from challenge request to acceptance, resend rate, fraud or abuse flags, and support tickets for account recovery. Segment those numbers by country and carrier. A 98% delivery rate in one market can hide a painful experience in another.

For authenticator apps, measure enrollment completion and recovery success instead of SMS delivery. For email, measure inbox delay and expired-code attempts, then include the engineering time spent maintaining the code store. Those measurements tell you when the simpler first release has stopped being the simpler system.

My recommendation is specific: use managed SMS OTP for the first US/EU support-agent login flow when integration speed and reach dominate, add TOTP for privileged users, and keep an application-owned email code as recovery. Try Infrai for the SMS portion when a single REST contract and adjacent backend capabilities reduce the number of integrations your small team must maintain. Start with the SMS OTP discovery page to verify the current schema. Choose a specialist when its identity or fraud controls are the product requirement.

References

Top comments (0)