Password reset: email only, or SMS as the backup channel?

Email-only is the right default for password reset. Here's when an SMS fallback earns its place, what it costs per recovery, and the US/EU rules that bind it.

Email-only is the correct default for password reset, and SMS belongs behind it as a narrow fallback rather than as a parallel option the user gets to choose. A reset that can be requested over SMS hands anyone who ports the number a second door into the account. On Infrai both channels sit behind the same key, so making SMS a fallback is a branch in your handler, not a second vendor contract.

The interesting question was never which channel is cheaper.

It’s which channel you want standing as the last line of defence once the other one has already failed — because account recovery is, by construction, the weakest authentication path you ship.

Three designs, and what each one actually buys

DesignRecovers the account whenExtra attack surfaceWhat each recovery bills
Email onlyThe mailbox is reachable and not suppressedNone beyond the mailbox itselfOne metered send
Email primary, SMS fallback on a hard signalThe mailbox bounced, was suppressed, or the domain rejects youPhone number, only for users who already verified oneOne metered send, plus an SMS send and an SMS check on the small slice that falls back
User picks the channel at request timeEither worksPhone number, for every account, alwaysAn SMS send plus an SMS check, every single time

The third row is the one to avoid, and not mainly for the money. If a user can always choose SMS, an attacker who has done a SIM swap never has to touch the mailbox. NIST’s SP 800-63B treats SMS as a restricted authenticator for exactly this reason, and the OWASP forgot-password guidance says the same thing more bluntly: the recovery channel sets your real account security, not the login form.

The middle row is what we’d build. The fallback fires on evidence, not on a user’s preference.

The fallback trigger should be a fact, not a timer

Most password-reset write-ups tell you to show a “didn’t get it? try SMS” button after 60 seconds. That’s a guess dressed up as UX. The deterministic signal is already in your account: a suppressed address will never receive the reset mail, whatever the user clicks.

Check it before you send anything.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/email/suppression/check/user@example.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "email": "user@example.com",
    "reason": "manual",
    "added_at": "2026-07-04T17:02:22.803322Z",
    "scope": "account",
    "attempt_count_blocked": 0,
    "suppressed": true
  }
}

suppressed: true is your fallback trigger. A hard bounce recorded weeks ago is worth more than any timer, and the check is free and rate-limited rather than billed per lookup.

The primary leg

Nothing exotic — a reset link with a short TTL, sent as a normal transactional email.

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "from": "no-reply@yourdomain.example",
    "subject": "Reset your password",
    "html": "<p>Use this link within 15 minutes: <a href=\"https://app.yourdomain.example/reset?token=6f1cbe2d9a5f4d0e\">reset your password</a></p>"
  }'
{
  "ok": true,
  "data": {
    "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac",
    "from_used": "no-reply@yourdomain.example",
    "mode": "raw",
    "accepted_recipients": ["user@example.com"],
    "suppressed_recipients": []
  }
}

Watch suppressed_recipients. An address that lands there was accepted by the API and dropped before the vendor — the send looks successful in your logs and the user gets nothing. That single field closes the most common password-reset support loop.

Do not text a reset link. Links in SMS get rewritten by carriers, flagged by anti-phishing filters, and pasted into group chats. Send a managed one-time code and keep the actual reset behind a session your server controls.

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

The gateway generates the code, stores it, expires it and counts attempts, so your database never holds a recoverable secret. You submit whatever the user typed to POST /v1/sms/verify with to and code, and it fails closed once the TTL or the attempt budget is gone.

The dispatcher, in Node 22

// reset-dispatcher.mjs — email primary, SMS fallback on a hard signal.
// Run: INFRAI_API_KEY=your_infrai_api_key node reset-dispatcher.mjs
import { randomUUID } from "node:crypto";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const FROM_EMAIL = "no-reply@yourdomain.example";
const APP_HOST = "https://app.yourdomain.example";

async function api(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
  });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
    throw Object.assign(new Error(e.message), { code: e.code, status: res.status });
  }
  return payload.data;
}

async function mailboxUsable(email) {
  const check = await api(`/v1/email/suppression/check/${encodeURIComponent(email)}`);
  return check.suppressed !== true;
}

async function mailResetLink(email, token) {
  const html = `<p>Use this link within 15 minutes: <a href="${APP_HOST}/reset?token=${token}">reset your password</a></p>`;
  return api("/v1/email/send", {
    method: "POST",
    body: JSON.stringify({ to: email, from: FROM_EMAIL, subject: "Reset your password", html }),
  });
}

async function textResetCode(phone) {
  return api("/v1/sms/otp", {
    method: "POST",
    body: JSON.stringify({ to: phone, template: "reset" }),
  });
}

async function requestReset(user) {
  const token = randomUUID();
  if (user.email && (await mailboxUsable(user.email))) {
    const sent = await mailResetLink(user.email, token);
    if (!sent.suppressed_recipients.length) {
      return { channel: "email", token, message_id: sent.message_id };
    }
    console.warn("recipient suppressed at vendor edge; falling back");
  }
  if (!user.phone_verified_at) return { channel: "none", token: null };
  const otp = await textResetCode(user.phone);
  return { channel: "sms", token: null, request_id: otp.request_id };
}

const outcome = await requestReset({
  email: "user@example.com",
  phone: "+15551234567",
  phone_verified_at: "2026-03-02T09:15:00Z",
});
console.log(JSON.stringify(outcome, null, 2));

Two details are load-bearing. The token is minted once and only ever travels down the email path, so an SMS recovery can’t resurrect a link the user never saw. And phone_verified_at gates the fallback: an unverified number is an attacker-supplied number.

Confirming it landed, without running a webhook

Neither channel needs you to expose a receiver. Both keep a queryable record.

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

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

The SMS read returns SMS_MESSAGE_NOT_FOUND for an id that isn’t yours; substitute the message_id a send handed back and you get state, attempt and failed_reason. Worth flagging: build your monitoring on the status read. GET /v1/sms/events/{id} gives a fuller timeline, but carrier filtering happens past the point the network reports on — a message can settle at “delivered to carrier” and never light up a handset — so treat the status read as the source of truth and the timeline as a bonus.

What a recovery costs, and how to read today’s number

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | grep -o '"id": *"sms.send"[^}]*}[^}]*}'

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

Read the billing shape first and the digits second, because the shape is what your design depends on. Verified 2026-07-27:

Leg of a recoveryRouteBilled onRate today
Reset link by emailPOST /v1/email/sendper_email$0.00046
Fallback code by SMSPOST /v1/sms/otpper_message$0.008395
Checking the codePOST /v1/sms/verifyper_call$0.005
Suppression check, status reads, event history—nothingfree, rate-limited

An email recovery bills once. An SMS recovery bills on the send and again on the check, and the check is charged whether the user typed the code right or not — that asymmetry, not any particular rate, is why the fallback should fire on evidence rather than on a button the user can always press. New accounts get $2 of free credit. GET /v1/account/usage is where you read what your recoveries actually consumed, per capability, instead of estimating from a table that ages.

The US and EU parts that actually bind

In the US, an alphanumeric sender won’t reach mobile subscribers and a long code carrying application traffic needs 10DLC brand and campaign registration before throughput is sane. In the EU, alphanumeric senders are normal but a stored phone number is personal data under GDPR: you need a lawful basis, a retention limit, and a deletion path. That’s a real cost of the fallback design, and it’s paid in policy rather than per message.

Where this is the wrong build

If SMS verification is the only thing you need and you want carrier lookup, silent network auth and WhatsApp fallback in one product, stick with a specialist — Twilio Verify and Vonage Verify both do more here than a general gateway will. Two Infrai caveats belong in your evaluation notes as well. Sending from your own domain is a paid-plan feature: POST /v1/email/domain/verify answers 402 on a standard key, and so does a send with a custom from, so budget for the upgrade before you promise your security team a branded reset address. And the SMS surface is built for outbound codes and notifications, not two-way conversations — there’s no inbound reply retrieval, so if a subscriber texting back is core to your flow, a specialist messaging API is the better home for that half.

The reason to be here isn’t the rate, it’s that the branch you just wrote has neighbours already on your account. The retry that staggers a burst of resets is POST /v1/queue/publish. The decision trail an auditor will ask for is POST /v1/logs/ingest. A fallback that throws goes to POST /v1/errors/capture, and the tenant who caused the SMS spend shows up in GET /v1/account/usage. One key issues all of them, so extending this dispatcher never means opening another account or reconciling a second bill.

References

Browse more sms developer guides