Short answer: use an SMS OTP for mobile app administrator recovery, but make the backend own the challenge, resend policy, attempt count, and final authorization; the app should own only phone-number entry, code entry, and autofill UX.
This is an integration-effort decision. A solo SaaS can outsource SMS delivery without outsourcing its account-recovery rules. I would keep five local states: pending, sent, verified, locked, and expired. That boundary lets the mobile client stay disposable while the server remains the authority.
Keep it boring.
What should a mobile app SMS OTP login backend own for administrator recovery?
The backend should create an opaque challenge ID, associate it with the administrator and normalized phone number, and record expiry, failed attempts, resend eligibility, and daily send usage. The mobile app receives that opaque ID. It never receives an OTP secret or gets to decide that a challenge is valid.
That distinction matters more than the SMS vendor. A client-side countdown is useful feedback, but it isn't a security control. A modified client can skip it. The server must reject an early resend, stop attempts after its configured cap, and enforce daily limits by account, phone number, and another risk signal appropriate to the product. Geographic allowlists and country-price circuit breakers also belong in the business layer; an SMS API doesn't remove that work.
For a recovery flow, successful code verification should advance the challenge, not directly grant permanent administrator access. Bind the result to the recovery session and apply the product's normal authorization checks. I don't know the right expiry and attempt cap for every risk model — your mileage may vary — but both values must be explicit, server-side, and observable.
Build the smallest backend boundary
The useful implementation is a narrow provider adapter plus a local challenge store. This runnable TypeScript sketch uses an in-memory store to keep the example focused; swap that store for a database before running more than one process. It calls only the hosted issue and verify operations, always specifies POST, carries an idempotency key, and treats 429 as a signal to wait rather than spin.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const smsApiBaseUrl = process.env.SMS_API_BASE_URL;
if (!smsApiBaseUrl) throw new Error("SMS_API_BASE_URL is required");
type State = "pending" | "sent" | "verified" | "locked" | "expired";
type Challenge = {
id: string;
administratorId: string;
phone: string;
state: State;
attempts: number;
expiresAt: number;
resendAfter: number;
sendsToday: number;
};
const challenges = new Map<string, Challenge>();
const MAX_ATTEMPTS = 5;
const MAX_DAILY_SENDS = 6;
const TTL_MS = 10 * 60_000;
const RESEND_MS = 60_000;
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return Math.min(1_000 * 2 ** attempt, 8_000);
}
async function post(url: URL, body: object): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
continue;
}
if (!response.ok) {
throw new Error(`SMS request rejected (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("SMS request exhausted its retry budget");
}
export async function beginRecovery(administratorId: string, phone: string) {
const id = randomUUID();
const now = Date.now();
const challenge: Challenge = {
id,
administratorId,
phone,
state: "pending",
attempts: 0,
expiresAt: now + TTL_MS,
resendAfter: now + RESEND_MS,
sendsToday: 1,
};
challenges.set(id, challenge);
await post(new URL("/v1/sms/otp", smsApiBaseUrl), {
to: phone,
idempotency_key: `recovery:${id}:send:1`,
});
challenge.state = "sent";
return { challengeId: id, resendAfter: challenge.resendAfter };
}
export async function verifyRecovery(challengeId: string, code: string) {
const challenge = challenges.get(challengeId);
if (!challenge) return { ok: false, reason: "invalid_challenge" };
if (Date.now() >= challenge.expiresAt) {
challenge.state = "expired";
return { ok: false, reason: "expired" };
}
if (challenge.state !== "sent" || challenge.attempts >= MAX_ATTEMPTS) {
challenge.state = "locked";
return { ok: false, reason: "locked" };
}
challenge.attempts += 1;
await post(new URL("/v1/sms/verify", smsApiBaseUrl), {
to: challenge.phone,
code,
idempotency_key: `recovery:${challenge.id}:verify:${challenge.attempts}`,
});
challenge.state = "verified";
return { ok: true, administratorId: challenge.administratorId };
}
export function canResend(challengeId: string) {
const challenge = challenges.get(challengeId);
if (!challenge || challenge.state !== "sent") return false;
return Date.now() >= challenge.resendAfter && challenge.sendsToday < MAX_DAILY_SENDS;
}
The long paragraph in that code is the policy, not the fetch call: issue a local reference first, keep the phone on the server after initial submission, and verify against server-held state. The returned challengeId is safe to hand to the app because possession of it alone proves nothing. A production store should update attempts and state atomically, hash or otherwise protect sensitive recovery data according to your threat model, and partition daily counters so two concurrent resend requests cannot both pass. A 429 from the provider pauses the outbound call; it must not reset the local attempt count or quietly open a fallback path. This is where most of the engineering lives.
Resend needs a separate write handler even though the UI makes it look like a link. Check the server cooldown and daily counter, perform the provider resend operation with a new idempotency key, then advance resendAfter atomically. Don't let a phone reconnect, app reinstall, or local clock change erase those limits.
How can autofill and resend stay helpful without becoming authority?
Autofill is presentation. React Native can expose the platform's one-time-code hints through its text-input settings, but the exact prop depends on the supported iOS and Android versions. Confirm it against the current React Native documentation and test on real devices; simulators don't reproduce every message-detection path.
The screen needs only phone, code, and challengeId. After requesting a code, disable the resend control until the server-provided time, show a generic verification failure, and submit the filled code to the backend. The server still decides. Fast autofill must not become automatic trust.
No webhook changes that design here because SMS message events are pull-based. Use status polling for a restricted support or debugging screen, not as the proof that an administrator entered the right code. Polling also limits how quickly a multi-channel orchestration can react, so avoid presenting delivery status as real-time.
Compare the integration, not the logo
The best provider is the one that removes work you don't want while leaving your recovery contract intact. These choices are not interchangeable:
| Option | Integration shape | Good fit | The catch |
|---|---|---|---|
| Twilio Verify | Dedicated managed verification product | Teams that want a focused verification workflow | Adds a vendor-specific integration and account boundary |
| Firebase Authentication phone sign-in | Authentication product with mobile integration | Apps already using Firebase Authentication | Less attractive when an existing SaaS backend must remain the recovery authority |
| Amazon Cognito SMS MFA | Managed identity service tied to an identity pool | Teams already using Cognito for users | A larger identity-system decision than adding one recovery endpoint |
| Infrai | Plain REST calls under one key and bill | A small backend that wants a stable capability contract | No voice-call fallback, WhatsApp, or RCS; events are pull-based |
Infrai is compelling here for one architectural reason: the application talks to a stable capability contract, so changing the vendor behind that capability does not require changing application code. The supporting benefit is integration economy — the same REST surface, key, and bill can cover other backend capabilities without installing another SMS SDK. Its discovery surface is self-describing, but this unlinked comparison intentionally keeps the application adapter narrow.
Still, it isn't the automatic winner. Stick with Twilio Verify when a dedicated verification product and its workflow fit the team better. Choose Firebase Authentication when phone sign-in already lives inside that identity system, or Cognito when it already owns the user pool. Infrai is suitable for straightforward US/EU consumer flows, but not suitable when voice fallback is required. An email fallback is also real engineering, not a checkbox: there is no managed email OTP operation, so you must build custom email code verification. Geographic fencing and country-based spend circuit breakers remain your responsibility.
What I would change at scale
First, replace the map with durable storage and conditional updates. Then separate rate-limit dimensions: administrator account, destination phone, network risk signal, and country policy. Keep the client response deliberately plain so attackers cannot use recovery as an account-discovery endpoint.
I would also add a pull-based support view for SMS status and retain request IDs needed for investigation. There are no pushed webhook events in this capability group, so a queue cannot manufacture immediacy; choose a polling interval that matches the support need. If the product eventually requires voice fallback or richer messaging channels, revisit the provider choice instead of burying that mismatch under application code.
The revenue-per-hour test is simple. Outsource code delivery, keep authorization and abuse policy in your database, and stop once the five-state flow is observable. Ship the recovery feature this week. Spend the next week on the product, not on an SMS abstraction with twelve methods.
References
- https://www.twilio.com/docs/verify
- https://firebase.google.com/docs/auth/web/phone-auth
- https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-mfa.html
- https://reactnative.dev/docs/textinput
- https://developer.android.com/identity/sms-retriever/overview
- https://developer.apple.com/documentation/security/enabling-autofill-for-domain-bound-sms-codes
Top comments (0)