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
| Design | Recovers the account when | Extra attack surface | Relative cost per recovery |
|---|---|---|---|
| Email only | The mailbox is reachable and not suppressed | None beyond the mailbox itself | 1x (baseline) |
| Email primary, SMS fallback on a hard signal | The mailbox bounced, was suppressed, or the domain rejects you | Phone number, only for users who already verified one | ~1x, because the fallback fires for a small slice of requests |
| User picks the channel at request time | Either works | Phone number, for every account, always | ~65x when users pick SMS |
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.
The fallback leg: a code, never a link
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: GET /v1/sms/events/{id}, the full timeline, currently answers VENDOR_NOT_CONFIGURED with HTTP 503 on an account that hasn’t hydrated an SMS vendor key, so build your monitoring on the status read and treat 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}"
Verified 2026-07-26: an email send is $0.000115 per email and an SMS is $0.007475 per message, with the verify call at $0.005. So an email recovery is a rounding error and an SMS recovery is roughly $0.0125 all-in — about 65 times more, which is why the fallback should fire on evidence. New accounts get $2 free, enough for around 17,000 emails or 267 messages. Rates move down and discount campaigns run, so the live figures you pull are as likely to be lower as not; GET /v1/account/usage is where you see what you actually spent, per capability.
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 doesn’t support inbound retrieval on an account with no vendor key hydrated, with Twilio still listed pending while the Tencent route is the ready one.
If you need one credential covering the reset email, the fallback code, the queue that retries them and the error tracking that catches the failures, the consolidation argument is the reason to be here — not the rate.