An SMS OTP login is a communications workflow with an authentication step attached. That distinction matters when a developer-tools app must prove what happened later. The mobile screen can make entry quick, but the backend has to own the challenge, the resend decision, and the delivery evidence.
Short answer: treat React Native as an untrusted client, and make the backend API own OTP creation, verification, autofill-friendly responses, resend policy, abuse prevention, and the audit record.
That is the build I would ship for a small SaaS. It keeps the app pleasant and the security boundary boring. Boring is good when one person is also handling support, billing, and the next release.
What should a mobile backend record when OTP autofill and repeated requests fail?
Start with a small contract. The app sends a normalized phone number to /auth/sms/start. The server returns an opaque challengeId, an expiration time, and an absolute resendAt timestamp. The app sends the challenge ID and the entered code to /auth/sms/verify. A resend request goes to /auth/sms/resend with an idempotency key.
The phone number is an input, not an identity proof. The challenge ID is a pointer, not a secret. The code is the secret and should travel only over TLS, never in logs, analytics events, crash reports, or URLs. Provider credentials stay on the server.
Autofill does not weaken that boundary. In React Native, autoComplete="sms-otp" and textContentType="oneTimeCode" can make the code field eligible for platform-assisted entry. The user can still paste the code. Neither setting should be treated as verification, and neither makes the resend timer authoritative.
The server checks the timer again. It also checks the challenge state, expiration, attempt count, account or phone history, and any IP or device signals used by the product. A modified app can call the endpoint early. Two devices can race. A reinstall can erase local state. The policy must survive all three.
There is no universal abuse threshold. I would start with reviewed configuration, measure false blocks, and change the policy from observed traffic rather than burying magic numbers in the client. I'm not sure a single daily ceiling can work across every country and carrier. Your mileage may vary.
The catch is that a stricter policy protects a messaging budget by increasing the chance of blocking a legitimate user. A looser policy does the reverse. That is a product decision, not a React Native setting, so record the policy version with each challenge.
When a request is refused, return a stable error category and a retry time. A 429 means the server has made a policy decision; it does not mean the client should retry in a tight loop. That one detail prevents a frustrating screen from becoming a small message-sending machine.
The smallest auditable implementation
The component below is intentionally an app-facing example. It contains no messaging-provider call and no credential. The backend response includes enough state for a good screen without allowing the screen to make a security decision.
import React, { useState } from "react";
import { Button, SafeAreaView, Text, TextInput } from "react-native";
const API_ORIGIN = "https://app.example.com";
type Challenge = {
challengeId: string;
expiresAt: string;
resendAt: string;
};
async function post<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${API_ORIGIN}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as T & { error?: string };
if (!response.ok) {
throw new Error(payload.error ?? `Request failed with ${response.status}`);
}
return payload;
}
export default function SmsOtpLogin() {
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [challenge, setChallenge] = useState<Challenge | null>(null);
const [message, setMessage] = useState("");
async function start() {
try {
const next = await post<Challenge>("/auth/sms/start", { phone });
setChallenge(next);
setMessage("Code sent");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Request failed");
}
}
async function verify() {
if (!challenge) return;
try {
await post<{ authenticated: true }>("/auth/sms/verify", {
challengeId: challenge.challengeId,
code,
});
setMessage("Signed in");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Verification failed");
}
}
async function resend() {
if (!challenge || Date.now() < Date.parse(challenge.resendAt)) return;
try {
const next = await post<Challenge>("/auth/sms/resend", {
challengeId: challenge.challengeId,
idempotencyKey: crypto.randomUUID(),
});
setChallenge(next);
setMessage("Code sent again");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Resend blocked");
}
}
return (
<SafeAreaView>
<TextInput
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
autoComplete="tel"
placeholder="Phone number"
/>
{!challenge ? <Button title="Send code" onPress={start} /> : null}
{challenge ? (
<>
<TextInput
value={code}
onChangeText={setCode}
keyboardType="number-pad"
autoComplete="sms-otp"
textContentType="oneTimeCode"
placeholder="Verification code"
/>
<Button title="Verify" onPress={verify} />
<Button title="Resend" onPress={resend} />
</>
) : null}
<Text>{message}</Text>
</SafeAreaView>
);
}
The backend creates a durable challenge record before asking a messaging service to deliver a code. That record needs a random identifier, a hashed or otherwise protected code representation, an expiry, a consumed flag, the attempt state, and a link to the application account or pending phone number. Store the send request ID and provider message reference too. Those fields turn “the user says it never arrived” into a queryable event trail. For a one-person SaaS, I would also record the policy version, normalized country, client version, and a correlation ID in the same transaction boundary where possible. Later, a support question can then be answered from one timeline instead of reconstructed from application logs, provider dashboards, and a spreadsheet assembled after the incident. That extra bookkeeping is not free: it adds storage, retention review, and access-control work, but without it the phrase “auditable delivery record” is mostly a promise.
Verification must update attempt state atomically. On success, consume the challenge so the same code cannot be reused. On expiry, return a normal expired-challenge result. On too many attempts, return a policy result. The exact response text can be friendly; the server-side event should be precise.
The audit record should answer five questions without storing the OTP itself: who or what requested the challenge, which destination was targeted, when the request was accepted, which delivery reference was returned, and how verification ended. Keep the raw phone number access-controlled or tokenized according to the product's privacy requirements. Compliance evidence is not a license to collect everything.
Delivery evidence is different from successful verification
An accepted send request does not prove that a handset received a message. A delivery report, where the selected communications service provides one, is a separate event. Verification is a third signal: it proves that someone supplied the code, not that the original device alone did so.
Record these events with a correlation ID:
-
challenge.created, with the policy version and expiration. -
message.requested, with a redacted destination and provider reference. -
message.status, with the source status and event timestamp. -
challenge.verified,challenge.expired, orchallenge.blocked.
Use the server's event time for ordering, retain the upstream timestamp as evidence, and make ingestion idempotent. Status updates can arrive late or be repeated. A status consumer that blindly inserts every notification will make a compliance report look busier without making it more accurate.
For a developer-tools product, I would expose a support view that searches by account ID, challenge ID, and correlation ID. It should show policy decisions and delivery references while masking the destination and code. The support view is operational infrastructure, not part of the login screen.
Testing the failure paths before shipping weekly
The happy path is cheap to test. The expensive bugs live around it. Test a resend at resendAt - 1 ms, two resend requests with the same idempotency key, two different keys arriving concurrently, a code submitted after expiry, and a code submitted after the final allowed attempt. Test a delayed delivery report arriving after successful verification. Test a repeated status event.
On the device, test paste, platform autofill, an empty code, a code with spaces, a replaced SIM, a reinstated app, and a second phone beginning the same challenge. Make the UI render the server's retry time instead of counting down as though local time were authoritative.
Instrument rates, not secrets: challenge creation, send acceptance, delivery outcomes, verification outcomes, resend frequency, and policy blocks. Alert on sudden changes by country, carrier prefix, account, IP range, and device signal. Avoid using one signal as a verdict; shared networks and mobile carriers create legitimate clusters.
The revenue-per-hour calculation is straightforward. A small, durable audit model pays for itself by making support and review work searchable. A large identity abstraction does not automatically do that. Ship the narrow flow this week, keep the provider adapter replaceable, and spend the next week on the failure mode your logs actually show.
When should this design change?
This approach is suitable when SMS is one authentication factor, the app has a modest recovery surface, and the team can own the policy boundary. The catch is that SMS remains a weak channel against SIM-swap and phishing risks, and delivery evidence will not satisfy every audit regime by itself. It is not suitable when the product requires phishing-resistant authentication, enterprise federation, mandatory voice recovery, or a compliance program that demands a particular evidence retention and access-control model. In those cases, use a different identity architecture built around those requirements before polishing the React Native form.
Email fallback is also a separate system. It needs code generation, storage, expiry, delivery, verification, abuse controls, and its own evidence model. Do not add an “email” button that silently bypasses the same controls. If recovery is important, model it as another challenge type with an explicit policy.
The standard is not “the SMS arrived.” The standard is that the system can explain what it requested, what the transport reported, what the user proved, and why each state transition was allowed. That is the boundary I would keep while the product grows, because it leaves room to outsource message transport without outsourcing the decision that protects the account.










