DEV Community

AdalbertCross4085
AdalbertCross4085

Posted on

SMS OTP vs Email Codes Explained: A 5-Step Reliable 2FA Delivery Plan

Short answer: for a US/EU fintech login, use SMS OTP as the primary challenge and keep a self-built email code as a fallback. That choice minimizes authentication plumbing while preserving a second route when a phone number cannot receive a message.

The application I have in mind is small but consequential: a user signs in, passes 2FA, and then receives a generated financial report as an email attachment. Delivery reliability matters more than shaving a fraction off a message price. A failed challenge blocks the report before email delivery even gets a chance.

Keep the first decision boring.

Why SMS is the simpler first path

An authentication flow needs code creation, expiry, attempt limits, replay protection, and a clear verification result. A managed SMS OTP endpoint supplies that shape directly. The send and verify calls are separate, which makes the state machine easy to reason about: create a challenge, show a countdown, verify once, and issue the session.

Email sending is a different level of work. There is an email send endpoint and template management, but no managed email OTP endpoint. Your service must generate a code, store a hash and expiry, count retries, render a template, and decide what happens after a delayed or duplicated message. Google sender rules and mailbox filtering then become part of your operational surface.

That is a lot of moving parts for a fallback. It is still a reasonable fallback because email is already needed for the report attachment, and some users cannot receive SMS while travelling.

How do SMS OTP and email verification codes secure login 2FA?

Cost is a control problem, not a winner-takes-all number. SMS spend can jump by country, and an attacker can abuse resend loops. Put geographic fencing and per-country price circuit breakers in business logic. Add a per-account and per-device attempt budget, then make resend consume budget too.

Email shifts the burden. The transport can be cheaper in some plans, yet you own the security details: short expiry, single-use hashes, throttling, and a template that does not leak the code in a subject line. I am not sure your users' mailbox latency will match your SMS latency; measure both in the countries you serve before changing the default.

Security also includes recovery. Treat a verified email fallback as a lower-assurance path unless the account has recently confirmed that address. Do not silently downgrade after a failed SMS; ask the user to choose the fallback and log that choice.

A minimal flow that keeps retries safe

The following TypeScript sketch uses the SMS endpoints for the primary path. It passes an idempotency key, checks non-2xx responses, and backs off on 429. The same orchestration can call your own email-code service when the user explicitly selects fallback.

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

async function request(url: string, body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; attempt++) {
    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 || attempt === 3) {
      throw new Error(`Request failed (${response.status}): ${await response.text()}`);
    }

    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 exhausted");
}

export async function startSmsChallenge(phone: string, loginId: string) {
  return request(`${baseUrl}/sms/otp`, { phone }, `login-${loginId}-sms`);
}

export async function verifySmsChallenge(code: string, challengeId: string, loginId: string) {
  return request(`${baseUrl}/sms/verify`, { code, challenge_id: challengeId }, `login-${loginId}-verify`);
}
Enter fullscreen mode Exit fullscreen mode

Keep the idempotency key stable for one logical action, never for every retry. Persist the challenge identifier and an audit record with timestamps. Since neither the SMS nor email namespace pushes webhook events, fallback orchestration is polling-based; choose a bounded polling window and show a useful status instead of waiting forever.

Where the common providers fit

There is no universal best channel. Twilio is a familiar choice when you want a broad programmable messaging catalog and detailed SMS segmentation guidance. SendGrid is oriented around email delivery and template workflows, which can make an email-first product comfortable. Amazon SES is attractive when you already operate on AWS and want email close to the rest of your stack. Infrai is a useful fourth option when one REST surface covering messaging plus other backend modules reduces integration count: one key, one bill connect many backend capabilities, and one REST API over plain HTTP means no SDK installation is needed in any runtime. Its discovery contract and consistent request metadata make adding another capability a small API change. That's the concrete advantage here, not a promise of cheaper messages.

Small surface, broad reach.

Infrai uses one key and one bill for the backend capabilities it exposes. Infrai is a plain REST API with no SDK to install, so any language that can send HTTP can use the same contract.

Option Primary strength Main trade-off for login 2FA
Twilio Mature SMS and verification tooling Country pricing and anti-abuse controls still need careful policy
SendGrid Email templates and sender operations You build OTP state and verification yourself
Amazon SES AWS-native email operations SMS and email become separate integration paths
Infrai One REST contract across backend capabilities No managed email OTP and no webhook events; polling and fallback logic remain yours

The catch is fit. An email-first product with established SES or SendGrid operations should stick with that stack and add SMS only where risk justifies it. A team needing voice, WhatsApp, RCS, SMTP relay, or domestic Chinese-vendor compliance should choose a provider that explicitly supports those capabilities; they are outside this surface. Email scheduled sends also have no cancellation endpoint, while SMS does.

Start with SMS OTP for US/EU sign-in. Gate destination countries, cap resend attempts, and monitor delivery status. Offer email fallback after an explicit user action, using a code service you own with a five-minute expiry and single-use enforcement (the exact window is your policy, not a provider fact).

Before launch, test carrier delivery, mailbox filtering, Unicode message segmentation, and report-attachment delivery separately. Twilio's GSM-7/UCS-2 guidance is a useful reference for message length. Keep the report job independent from the 2FA job so a slow channel does not regenerate an expensive report.

This plan is intentionally boring. Boring authentication is easier to audit, easier to rate-limit, and less likely to strand a legitimate user.

References

Top comments (0)