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 2FA design that doesn’t fall over is one challenge record, two delivery channels and a fallback that fires on evidence — and the reason it stays that small on Infrai is that both channels live behind one credential, so the fallback is a branch rather than a second integration. 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.

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, an order of magnitude below SMS
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 plan caveat on that same leg — sending from your own domain sits on the Pro tier. GET /v1/discovery/email.domain.verify publishes minimum_tier: "pro" for exactly that reason, so you can check it in code before a launch rather than discovering it in a support thread. 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}"

A completed SMS login is two billable calls — the challenge and the verify — while the email leg is one. Here’s what those three routes were reading on 2026-07-27, straight out of the first call above:

RouteMeterRate, read 2026-07-27
POST /v1/sms/otpper_message$0.008395
POST /v1/sms/verifyper_call$0.005
POST /v1/email/sendper_email$0.00046

Don’t copy those into a spreadsheet. Copy the curl instead — the ratio is the durable part, and the ratio says an email challenge costs well over an order of magnitude less than an SMS one, which is the whole reason the fallback direction is worth arguing about. Status reads, suppression lookups and signature reads are free and rate-limited, so instrumenting this costs nothing. Rates here tend to fall and campaigns come and go, so GET /v1/account/usage is the number that settles an argument about what you actually spent.

The parts of this design that aren’t sending are on the same key as well, which is where a two-vendor stack starts costing you time rather than money. The address that keeps bouncing goes on POST /v1/email/suppression/add. The delivery receipt for a code a user swears never arrived comes from GET /v1/sms/status/{id}. The issuer throwing at 3am lands in POST /v1/errors/capture with the challenge id attached, so the postmortem starts from a stack trace instead of a grep. Each of those is one more call on the credential you already have — no second account, no second SDK, no second invoice to reconcile at the end of the month.

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: GET /v1/discovery/sms.otp currently lists Tencent as the ready vendor with Twilio still pending, and there’s no outbound delivery webhook, so everything above is polling by design.

References

Browse more sms developer guides