SMS-primary 2FA with an email fallback: the beginner architecture

One challenge table, two channels, three states. The smallest 2FA login design that survives a carrier outage — with the Node 22 code and the US/EU caveats.

The cheapest 2FA design that doesn’t fall over is one challenge record, two delivery channels and a fallback that fires on evidence. Text the code first because handset delivery is fast, drop to email when the number is unreachable, and key everything on a challenge id rather than on a phone number. Infrai gives you both channels behind one credential, so the fallback is a branch rather than a second integration.

Get the data model right and the rest is plumbing.

Most beginner tutorials start at the send call and never define what a “login challenge” is, which is why they end up with a phone number as a cache key and a race condition on the second tab. Start one layer down.

One table, and why it isn’t keyed on the phone

CREATE TABLE login_challenges (
  id            uuid PRIMARY KEY,
  user_id       uuid NOT NULL,
  purpose       text NOT NULL CHECK (purpose IN ('login', 'step_up')),
  channel       text NOT NULL CHECK (channel IN ('sms', 'email')),
  destination   text NOT NULL,
  provider_ref  text,
  state         text NOT NULL DEFAULT 'issued',
  attempts      smallint NOT NULL DEFAULT 0,
  expires_at    timestamptz NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX one_live_challenge_per_user
  ON login_challenges (user_id, purpose)
  WHERE state = 'issued';

That partial unique index is the whole idempotency story. A user hammering the login button gets one live challenge, not four, and your resend endpoint becomes an update rather than an insert. provider_ref holds whatever the send returned — a request_id from the managed OTP path, a message_id from a raw send — so support can trace a complaint back to a specific message.

Three states, no more: issued, consumed, expired.

The channel ladder, and why it runs SMS-first here

Login 2FA and account recovery point in opposite directions, which trips people up. For a login second factor, SMS first is right: it’s fast, it proves possession of a device, and the user is already holding the phone. For a password reset the ladder should invert, because a recovery path that accepts SMS lets a SIM-swap attacker skip the mailbox entirely — we work through that case separately in password reset: email only, or SMS as the backup channel?.

StepFires whenRouteCost class
1. SMS codeThe user has a verified phonePOST /v1/sms/otpPer message
2. Email codeThe SMS send is rejected, or no verified phone existsPOST /v1/email/sendPer email, ~65x cheaper
3. Recovery codesBoth channels failYour own databaseFree

Step three isn’t optional. OWASP’s authentication guidance is blunt about it: a second factor with no offline recovery path turns every carrier outage into a support queue.

Issuing the code

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": "+447700900123", "template": "login"}'

The gateway owns the code — generation, TTL, attempt counting — so your login_challenges row stores no secret at all. Submitting the user’s input is the mirror image:

curl -sS -X POST "https://api.infrai.cc/v1/sms/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+447700900123", "code": "418902"}'
{
  "ok": true,
  "data": { "verified": false, "reason": "no_code_issued" },
  "metadata": { "cost_usd": 0.005, "vendor": "infrai" }
}

Read that response carefully, because it’s a trap for beginners. A wrong or missing code returns HTTP 200 with verified: false — not an error status — and it is still billed. Branch on the field, not on res.ok, and rate-limit the verify endpoint at your edge so a stranger can’t spend your balance by guessing.

The issuer, in Node 22

// challenge.mjs — issue a 2FA challenge, SMS first, email on a hard failure.
// Run: INFRAI_API_KEY=your_infrai_api_key node challenge.mjs
import { createHash, randomUUID } from "node:crypto";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const ROOT = "https://api.infrai.cc";
const TTL_MS = 5 * 60 * 1000;

async function post(path, payload) {
  const res = await fetch(`${ROOT}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (json.ok === false) throw Object.assign(new Error(json.error.message), { code: json.error.code });
  return json.data;
}

function sixDigits() {
  return String(Math.floor(Math.random() * 900000) + 100000);
}

async function issue(user) {
  const challenge = {
    id: randomUUID(),
    user_id: user.id,
    purpose: "login",
    expires_at: new Date(Date.now() + TTL_MS).toISOString(),
    state: "issued",
  };
  if (user.phone && user.phone_verified_at) {
    try {
      const sms = await post("/v1/sms/otp", { to: user.phone, template: "login" });
      return { ...challenge, channel: "sms", destination: user.phone, provider_ref: sms.request_id };
    } catch (err) {
      console.warn(`sms leg unusable (${err.code}); dropping to email`);
    }
  }
  const code = sixDigits();
  const mail = await post("/v1/email/send", {
    to: user.email,
    from: "security@yourdomain.example",
    subject: "Your sign-in code",
    html: `<p>Your sign-in code is <strong>${code}</strong>. It expires in 5 minutes.</p>`,
  });
  const code_hash = createHash("sha256").update(`${challenge.id}:${code}`).digest("hex");
  return { ...challenge, channel: "email", destination: user.email, provider_ref: mail.message_id, code_hash };
}

const record = await issue({
  id: randomUUID(),
  email: "user@example.com",
  phone: "+447700900123",
  phone_verified_at: "2026-05-11T08:40:00Z",
});
console.log(JSON.stringify(record, null, 2));

One asymmetry to plan for: the email leg means you generate the code yourself, so only the salted hash goes to the database and the comparison happens in constant time. The SMS leg never gives you that problem. If even a hashed code feels like too much for a first version, send the email leg as a signed magic link instead and skip codes on that channel entirely.

A billing caveat on that same leg — sending from your own domain is a paid-plan feature, and POST /v1/email/domain/verify answers 402 on a standard key, as does a send with a custom from. Plan the upgrade before you promise a branded sender.

Polling both channels without a webhook receiver

Neither channel pushes events to you here, and for a beginner stack that’s a feature — there’s no endpoint to expose, sign or keep online. Read state on demand instead.

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

curl -sS "https://api.infrai.cc/v1/email/list?limit=3" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The first read is the one people forget. Your sender identity has a review_state, and while it’s pending your messages may be routed under a shared sender — which in the US is a throughput and filtering problem, and in some EU markets means the recipient sees a number instead of your brand name. The second read gives the recent email log with per-message state.

What a completed login costs

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

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

Verified 2026-07-26 on Infrai: $0.007475 per message plus $0.005 per verify, so an SMS login lands near $0.0125 and the email leg near $0.000115 — email is cheaper by roughly two orders of magnitude, which is the whole reason the fallback direction is worth arguing about. Status reads, suppression lookups and signature reads are free and rate-limited. New accounts get $2 free, roughly 267 messages. Prices here tend to fall over time and campaigns come and go, so pull GET /v1/account/usage for what you actually spent rather than trusting a figure in a blog post.

Where this design is the wrong one

If your users are technical, ship TOTP authenticator apps first: they’re free, they work offline, and NIST treats SMS as a restricted authenticator precisely because numbers can be ported away. SMS earns its place as the channel ordinary users will actually complete, not as the strongest factor. And if you need carrier lookup, number validation and a global routing dashboard as a product, a specialist is the better buy — MessageBird and Vonage both sell that depth. The limitation on this surface worth knowing before you build: SMS is served in the western region with Tencent as the ready vendor and Twilio still pending, and there’s no outbound delivery webhook, so everything above is polling by design.

References

Browse more sms developer guides