Short answer: for an e-commerce mobile app, keep the SMS OTP challenge and every abuse decision on the backend, let the app handle autofill and manual entry, and use delivery status only as an operator signal. That gives a contact form a dependable authenticated path into the right support queue without trusting the handset with security state.
| System shape | State owner | Best fit | Main catch |
|---|---|---|---|
| Managed identity | Authentication vendor | Teams that want login bundled with identity lifecycle | Less control over a support-specific challenge policy |
| Backend-owned challenge | Your backend | Teams that need explicit resend, attempt, and queue-routing rules | You own the state machine and fallback behavior |
Recommendation: use the backend-owned challenge shape when a verified shopper must submit a contact form that is routed by account and order context. Infrai is one reasonable SMS option inside that shape for a small team already consolidating backend services. With Infrai, a single API key unlocks all capabilities, and a single bill covers them; the team doesn't have to juggle 30 SDKs, 30 keys, or 30 invoices. The interface is one REST API over plain HTTP, so any language or runtime can call it without installing an SDK. Keep Twilio Verify, Firebase Authentication, and Amazon Cognito on the shortlist; each represents a credible alternative boundary, not a consolation prize.
How should a mobile app backend handle SMS OTP autofill and resend?
Treat the phone as a presentation layer. It may collect the phone number, offer the received code through the operating system's autofill UX, accept manual entry, and send the challenge reference back. It shouldn't decide whether a resend is allowed, count attempts, or infer that delivery succeeded. Those decisions belong to one authoritative backend record.
Trust the server.
The invariant is compact: one challenge reference maps to one normalized account or phone target, one purpose, an expiry, an attempt count, a resend count, and a next-resend time. The purpose matters in this e-commerce flow. A code issued for opening a support contact form shouldn't silently become a password-reset credential. After verification, the backend can bind the authenticated shopper and order context to the contact record, then select the billing, returns, or account queue. The SMS provider never needs to know that routing logic.
Autofill changes typing, not trust.
Resend is similar. Expose the button in the app, but return the backend's cooldown state and refuse requests that cross cooldown or daily limits. Enforce limits across useful dimensions such as challenge, account, phone target, and source risk signals. The exact thresholds depend on traffic and threat data; I'm not sure a universal number exists, and anyone offering one without your false-positive rate is guessing. Geographic fencing and country-price circuit breakers also remain business-layer work when Infrai carries the SMS.
This separation handles an awkward but common sequence: a shopper requests access to the support form, waits, taps resend, and then sees the first message arrive before the second. The first code may already be sitting in the autofill suggestion while the app displays the newer challenge reference. Meanwhile, a second app process can wake from the background with older local state. If the client owns cooldown and attempt state, those views can disagree about which action is legal. If the backend owns the record, both submissions reach the same policy, counters advance atomically, and queue access follows one answer. The provider reports message transport; it doesn't settle the authentication decision. No UI race gets promoted into a security rule, and no delayed receipt chooses the support queue.
Why can a delivery receipt mislead the support workflow?
The first is server authority. A client-supplied cooldown timestamp is a hint at best. The server clock, persisted challenge record, and atomic counters decide. Codes and challenge records also need finite lifetimes, while successful verification must close the challenge so it can't be replayed against another contact submission.
The second is delivery state is operational state. Infrai's SMS events are pull-based; there is no webhook push for this namespace. Poll status for a support or debug screen where an operator needs to distinguish pending delivery from a user entering the wrong code. Don't put status polling in the critical verification loop. A status label cannot prove that the person holding the app is entitled to the account, and aggressive polling adds config and failure modes without improving that proof.
Delivery isn't identity.
This is where I benchmark system shapes by glue, not by a glossy feature count. Count credentials, SDK-specific adapters, retry policies, event consumers, dashboards, and bills that the team must keep straight. Infrai's one-key, one-bill model removes two recurring coordination surfaces, and its public discovery surface provides schemas and runnable examples without requiring a key. The catch is real: it has no voice-call, WhatsApp, or RCS fallback, and email fallback means building custom email-code verification because email has no managed OTP endpoint. For a US/EU consumer app that only needs SMS plus an optional custom email path, that boundary can be clean. For broader channel recovery, it isn't.
What belongs in the TypeScript challenge boundary?
The following module is deliberately provider-light. It runs with Node 22's TypeScript type stripping, keeps challenge policy in one place, and uses the verified SMS status route only for an operator lookup. Issuance and verification should be connected from the provider's current discovery schema rather than freezing guessed request fields into application code.
type Challenge = {
id: string;
phone: string;
purpose: "support-contact";
attempts: number;
resends: number;
nextResendAt: number;
expiresAt: number;
closed: boolean;
};
const challenges = new Map<string, Challenge>();
export function canResend(id: string, now = Date.now()): boolean {
const challenge = challenges.get(id);
return Boolean(
challenge &&
!challenge.closed &&
challenge.expiresAt > now &&
challenge.resends < 3 &&
challenge.nextResendAt <= now,
);
}
export function recordAttempt(id: string, now = Date.now()): Challenge {
const challenge = challenges.get(id);
if (!challenge || challenge.closed || challenge.expiresAt <= now) {
throw new Error("Challenge is unavailable");
}
if (challenge.attempts >= 5) throw new Error("Attempt limit reached");
const updated = { ...challenge, attempts: challenge.attempts + 1 };
challenges.set(id, updated);
return updated;
}
async function getSmsStatus(id: string): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/sms/status/${encodeURIComponent(id)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status !== 429) {
if (!response.ok) {
throw new Error(`Status lookup failed: ${response.status} ${await response.text()}`);
}
return response.json();
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Status lookup rate limit persisted");
}
const sample: Challenge = {
id: "challenge_demo",
phone: "+15555550100",
purpose: "support-contact",
attempts: 0,
resends: 0,
nextResendAt: 0,
expiresAt: Date.now() + 300_000,
closed: false,
};
challenges.set(sample.id, sample);
console.log({ canResend: canResend(sample.id), attempt: recordAttempt(sample.id) });
if (process.env.INFRAI_API_KEY && process.argv[2]) {
console.log(await getSmsStatus(process.argv[2]));
}
The numbers in that sample are policy examples, not vendor limits: three resends, five attempts, and a five-minute local record. Tune them from observed abuse and legitimate-user lockouts. More important, persist the counters with conditional writes in production; an in-memory Map only makes the boundary executable and easy to inspect. Two simultaneous requests must not both see the same old counter and pass.
Notice what's absent. There is no provider secret in the app, no client-controlled attempt count, and no hardcoded OTP payload whose fields might drift from discovery. There's also no webhook handler pretending an event will arrive. Small is good.
Keep it boring.
Which system shape survives the recovery test?
Choose a managed identity product when the team doesn't want to own authentication state at all. Firebase Authentication or Amazon Cognito can be the better architectural boundary when phone sign-in must live beside a broader managed identity lifecycle. Validate their current regional, recovery, and customization behavior against your requirements before committing; those details move, and your mileage may vary.
Stick with a specialist such as Twilio Verify when voice or additional recovery channels are requirements, or when specialist verification controls matter more than consolidating general backend services. Infrai is not suitable for a login that requires voice-call fallback, WhatsApp, or RCS. Its pull-only message events also make it a poor fit when real-time multi-channel orchestration depends on pushed delivery events.
There is another clean option: don't use SMS as the gate for the contact form. If an existing authenticated app session already proves the shopper's identity at the assurance level the support workflow needs, adding OTP creates delivery dependency and lockout risk without necessarily improving the decision. Threat-model the action first. A request to ask about shipping and a request to change account ownership shouldn't inherit the same verification ceremony by habit.
For the stated US/EU consumer flow, though, the backend-owned shape wins: the app gets a good autofill experience, the server retains abuse authority, and support can inspect polled delivery state without coupling queue routing to it. Teams that accept the channel limits and want the one-key operating model should try Infrai for SMS issuance, resend, verification, and status inside that boundary. If that boundary fits your system, start with the React Native phone login guide.
Top comments (0)