SMS OTP or email OTP for 2FA login: security first, then latency

NIST treats these two channels very differently, and the gap decides the design. A security-led comparison for US and EU SaaS logins, with the managed OTP loop in Node 22.

Short answer for a SaaS serving US and EU users: make SMS the second factor for accounts that have a verified phone number, keep email codes for recovery rather than for step-up authentication, and offer an authenticator app to anyone who can be bothered. Infrai runs both channels from one key — POST /v1/sms/otp with a managed code, POST /v1/email/send for the mail half — so the choice is a routing decision rather than two procurement decisions.

The reason to lead with security rather than deliverability is that the two channels aren’t equally recognised. NIST’s SP 800-63B treats out-of-band authentication over the public telephone network as restricted: still permitted, but you’re expected to assess the risk, tell users about it, and offer an alternative that isn’t restricted. Email gets treated worse than that. The same guidance excludes channels that don’t prove possession of a specific device — email among them — from counting as out-of-band authenticators at all, on the reasoning that a mailbox is not a device and is frequently reachable from the very session you’re trying to authenticate.

That asymmetry survives every deliverability argument, and it’s the part the comparison posts tend to skip.

The two channels, on the axes that decide it

SMS OTPEmail OTP
NIST standingrestricted out-of-band authenticatornot an out-of-band authenticator
Typical arrivalsecondsseconds, unless a receiving server greylists a new sender and defers it
Dominant failurecarrier content filtering, unregistered sender IDspam classification, greylisting, mailbox full
Who you can’t reachlandlines, some VoIP numbers, users who changed SIMusers whose mail provider silently quarantines transactional mail
Attacker’s cheapest pathSIM swap, port-out, malicious SS7 accesscredential stuffing the mailbox itself
Same-device weaknesscode visible on a locked screenmail client open in the next browser tab

Read the last row twice. Email OTP on a desktop login is often no second factor at all, because the attacker who has the password very often has the mailbox — that’s what makes it a recovery mechanism rather than an authenticator. SMS at least forces possession of a phone number, and a SIM swap takes effort and leaves a trail.

Use the managed loop, not your own code generator

The classic weakness in hand-written login code is a six-digit code parked in Redis with no attempt counter and a TTL nobody enforces. Infrai’s OTP routes generate, store, expire and rate-limit the code on the gateway, so your application never holds the secret at all.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/otp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550142", "template": "login"}'

The response carries a request id and nothing sensitive:

{
  "ok": true,
  "data": { "request_id": "otp_7f3c19b2e5a4487d", "sent": true },
  "metadata": { "request_id": "req_5349a40eca7f4eff", "latency_ms": 812, "vendor": "tencent_sms" }
}

Verification is the mirror image — you submit what the user typed, and the gateway decides:

curl -sS -X POST "https://api.infrai.cc/v1/sms/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550142", "code": "418902"}'

Two behaviours to design around before you ship

The first is a billing detail with a security consequence. A verify call is charged whether or not it succeeds — including the case where no code was ever issued for that number, which comes back as verified: false with a reason of no_code_issued:

{
  "ok": true,
  "data": { "verified": false, "reason": "no_code_issued" }
}

At $0.005 per verify call, verified on 2026-07-26, an unthrottled /v1/sms/verify endpoint is a small denial-of-wallet target: ten thousand junk submissions is $50 of your money spent proving nothing. Put your own counter in front of it — three attempts per number per code, then a hard cooldown. Confirm the current rate before you model it:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Rates on this platform drift downward and discount campaigns run, so treat that figure as a reading taken on one day rather than a constant.

The second behaviour is a genuine trap for a login flow. A recipient that isn’t valid E.164 doesn’t come back as a 400 — it arrives as HTTP 503 VENDOR_DOWN with retryable: true, and the real explanation is only in the human-readable message. A user typing their number with brackets and spaces will therefore look, to a naive client, exactly like a carrier outage. If your login controller retries 5xx blindly, it will loop on a permanently bad input and eventually show the user “service unavailable” for what is really a validation error.

// otp-controller.mjs — Node 22 ESM. Send, then verify, with the 503 trap handled.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const E164 = /^\+[1-9]\d{7,14}$/;
const attempts = new Map();

async function call(path, payload) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(10_000),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const e = json.error ?? {};
    // A vendor-channel 503 whose message quotes the input is permanent, not transient.
    const permanent = /E\.164|must be|invalid/i.test(e.message ?? "");
    const err = new Error(e.message ?? `HTTP ${res.status}`);
    err.code = e.code;
    err.permanent = permanent || e.retryable === false;
    throw err;
  }
  return json.data;
}

export async function startLogin(phone) {
  if (!E164.test(phone)) throw new Error("normalise to E.164 before calling the gateway");
  attempts.set(phone, 0);
  return call("/v1/sms/otp", { to: phone, template: "login" });
}

export async function finishLogin(phone, code) {
  const used = attempts.get(phone) ?? 0;
  if (used >= 3) throw new Error("too many attempts; request a new code");
  attempts.set(phone, used + 1);

  const data = await call("/v1/sms/verify", { to: phone, code });
  if (!data.verified) return { ok: false, reason: data.reason ?? "incorrect" };
  attempts.delete(phone);
  return { ok: true };
}

Normalising to E.164 client-side before the call turns that whole class of confusion into a form-validation message, which is where it belongs.

Cost per verified login, and where the money goes

One SMS OTP plus one successful verify is roughly $0.0125 at the rates above — the send at $0.007475 per message and the verify at $0.005 per call — and a new account starts with $2 of free credit. Email is the cheaper channel by a wide margin per message, which is exactly why the temptation to use it as the primary factor exists. Check your own spend split rather than modelling it:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The breakdown comes back keyed by capability, so sms.otp, sms.verify and email.send each show their own call count and cost. Per-tenant attribution is a query against that, not a reconciliation project across three vendors.

When a specialist is the right call

Twilio Verify is the obvious comparison, and if verification is the whole of your product surface it’s the better buy — it ships silent network authentication, WhatsApp and voice channels, and a fraud-scoring layer that a general-purpose gateway doesn’t try to match. Vonage’s Verify API is similar in shape and strong on carrier reach in markets where a single upstream can struggle.

Two limitations on the Infrai side deserve a straight answer. There’s no X-RateLimit-* or Retry-After header on any route, so you can’t read your remaining budget — you find the ceiling by hitting it, which means your own limiter has to be the authority. And the SMS vendor pool currently lists tencent_sms as key-ready with twilio pending, so plan around a single upstream for now and check vendors_ready in discovery before you assume otherwise.

If your users are high-value — admin consoles, payment approvals, anything where an account takeover is a five-figure event — neither channel here is the right tool. Move those accounts to TOTP or a passkey and use SMS only as the recovery path, with a notification email fired on every recovery attempt so a silent takeover isn’t silent.

References

Browse more sms developer guides