DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

US/EU 2FA Delivery: Email Verification Code or SMS OTP for Login Security

The constraint that changes this choice is recovery time: a one-person SaaS cannot spend a week chasing carrier filters or mailbox reputation when an SMS OTP or email verification code fails during login 2FA in the US or EU.

Short answer: start with email verification codes for most US/EU consumer logins, then add SMS as a recovery or step-up channel when your threat model and users justify its operational cost. Neither channel is a complete second factor by itself; pair either one with rate limits, device/session controls, and a stronger factor for high-risk actions.

What should a login 2FA design optimize: cost, delivery, or security?

Treat the channel as one part of a system. The useful comparison is not “which message arrives fastest?” It is “which failure can I detect, contain, and recover from during a Tuesday release?”

Concern Email code SMS OTP
Implementation SMTP provider, domain authentication, bounce handling SMS provider, phone normalization, carrier delivery states
Common delivery risk Spam placement, throttling, mailbox rules Carrier filtering, recycled numbers, roaming, handset loss
Account-takeover risk Mailbox compromise and forwarding rules SIM swap, number porting, interception
Cost shape Usually predictable per message, with sender reputation work Per-message and country-dependent; long Unicode messages can segment
Best fit Default login verification and low-friction recovery Step-up checks, users without reliable email, or a second channel

“Cheapest” is a property of the whole workflow. A low message rate can still be expensive if support tickets, retries, and fraud reviews dominate revenue per hour.

The accounting gets more interesting when a user presses resend. A naive endpoint creates a new code, sends it through a second queue, and leaves both values valid. The user enters the first one, sees an error, and tries the second; support sees two messages and assumes a provider outage. A better policy records one active challenge per account and destination, applies a cooldown, and makes a resend either reuse the active challenge or explicitly revoke it. That policy should be the same for email and SMS, while the adapters expose different delivery metadata. In the US, mailbox-domain reports may reveal a filtering pattern; in the EU, a carrier or country route may show a different delay. Keep those dimensions in your event schema. They are more useful than a single global delivery percentage, because a global average can hide one country where every recovery attempt is failing.

How do SMS OTP and email codes fail in real login flows?

Email has a visible queue: accepted by your SMTP relay, delivered to a mailbox, then opened by a person. Instrument each transition. Google’s sender guidance calls for authentication, clear identity, and low complaint rates; without that hygiene, a correct code can land in spam or be delayed.

SMS has a different shape. A message may be split into segments when it leaves the basic GSM-7 character set or exceeds a segment limit. Twilio’s character-limit reference documents GSM-7 versus UCS-2 behavior, which is why a curly apostrophe or non-ASCII brand name can change segmentation and delivery characteristics. Keep the body short and ASCII when possible.

My first useful test is deliberately boring: issue 100 codes in a staging-like flow across representative US and EU addresses and numbers, record time-to-arrival, and expire every code. Do not treat that sample as a benchmark of the public internet. Your mileage may vary by carrier, mailbox, language, and time of day.

A code should be single-use, bound to an authentication attempt, and invalid after a short window. Store a digest, not the raw value, and compare in constant time. Cap attempts per account, IP, device, and destination. Return the same response for an existing and a nonexistent account so enumeration is harder.

Small things matter.

What is the smallest implementation that remains safe?

The transport adapter should be replaceable. The login service owns policy; email and SMS adapters only deliver text.

type Channel = "email" | "sms";

type Challenge = {
  id: string;
  destination: string;
  digest: string;
  expiresAt: number;
  attempts: number;
  used: boolean;
};

async function createChallenge(
  channel: Channel,
  destination: string,
  send: (channel: Channel, destination: string, body: string) => Promise<void>,
  hash: (value: string) => Promise<string>,
): Promise<Challenge> {
  const code = String(Math.floor(100000 + Math.random() * 900000));
  const challenge: Challenge = {
    id: crypto.randomUUID(),
    destination,
    digest: await hash(code),
    expiresAt: Date.now() + 5 * 60_000,
    attempts: 0,
    used: false,
  };

  await send(channel, destination, `Your sign-in code is ${code}. It expires in 5 minutes.`);
  return challenge;
}
Enter fullscreen mode Exit fullscreen mode

In production, use a cryptographically secure random generator and persist the challenge before sending, with an idempotency key so a retry does not create two valid codes. Log metadata such as channel, country, provider response, latency, and outcome, but never log the code itself.

Email is not suitable when the mailbox is shared, routinely offline, or already the recovery path for a compromised account. SMS is not suitable as the sole defense for high-value transfers or administrator access because phone numbers can be reassigned and SIMs can be socially engineered. In those cases, use passkeys or a TOTP authenticator and keep email/SMS for recovery with extra checks.

Ship it. Stick with a single channel when your volume is tiny and the alternative would double operational surface area. Add a second channel only after you can measure delivery, retries, abuse, and recovery completion by region.

At scale I would add a policy engine that chooses a channel per risk signal, a dead-letter queue for provider callbacks, and dashboards split by US/EU carrier and mailbox domain. That adds work, because the policy has to account for destination changes, repeated resends, risk elevation, and a user who has access to neither channel while still preserving a non-enumerating response. It also turns “the code never arrived” from a vague support thread into a bounded incident.

References

Top comments (0)