React Native phone login: autofill, a proxy backend and abuse caps
The API key can't ship in the bundle. Here's the two-endpoint proxy, an OTP template Android autofill can read, and timers that survive backgrounding.
A mobile app can’t hold an SMS credential. Anything shipped in a React Native bundle is readable — JS bundles are trivially extracted from an APK, and a stolen key means somebody else’s marketing campaign on your invoice. So the shape of this integration is fixed before you write a line: your app talks to your server, and your server talks to Infrai’s POST /v1/sms/otp and POST /v1/sms/verify.
That constraint is the same whether you use Infrai, Twilio or Sinch. What changes between them is how much of the rest — templates, timers, attempt caps — arrives in a client SDK versus getting written by you. This piece covers the written-by-you version, because it’s the one that works identically on Expo, bare React Native and a future web build.
Two endpoints, and nothing else
Your server exposes exactly two routes to the app. Neither one returns a passcode, and neither one accepts a passcode from anywhere but the user.
import { createServer } from "node:http";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY missing (locally: your_infrai_api_key)");
const BASE = "https://api.infrai.cc";
const TEMPLATE = "<#> 419-283 is your Acme code. Never share it. abcd1234efg";
const attempts = new Map();
const budget = (id, max, windowMs) => {
const now = Date.now();
const hits = (attempts.get(id) ?? []).filter((t) => now - t < windowMs);
hits.push(now);
attempts.set(id, hits);
return hits.length <= max;
};
async function upstream(path, payload) {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(json?.error?.message ?? `HTTP ${res.status}`), { status: res.status });
return json.data;
}
const readJson = (req) => new Promise((resolve, reject) => {
let raw = "";
req.on("data", (c) => { raw += c; if (raw.length > 4096) reject(new Error("body too large")); });
req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch (e) { reject(e); } });
});
createServer(async (req, res) => {
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
try {
const body = await readJson(req);
const phone = String(body.phone ?? "");
if (!/^\+[1-9]\d{7,14}$/.test(phone)) return send(400, { error: "phone must be E.164" });
if (req.url === "/otp/start" && req.method === "POST") {
if (!budget(`send:${phone}`, 3, 600_000)) return send(429, { error: "too many sends" });
await upstream("/v1/sms/otp", { to: phone, template: TEMPLATE });
return send(200, { cooldownUntil: Date.now() + 45_000 });
}
if (req.url === "/otp/check" && req.method === "POST") {
if (!budget(`check:${phone}`, 5, 600_000)) return send(429, { error: "too many attempts" });
const data = await upstream("/v1/sms/verify", { to: phone, code: String(body.code ?? "") });
return send(200, { verified: !!data.verified });
}
return send(404, { error: "no such route" });
} catch (err) {
return send(err.status === 400 ? 400 : 502, { error: err.message });
}
}).listen(3000);
The budget() calls are load-bearing. A mobile client is a stranger — you cannot trust its cooldown, its disabled button or its debounce, because a determined caller skips the app entirely and posts to /otp/check in a loop. Notice too that the phone number is validated on the way in and the upstream error is flattened to a 502 on the way out: the app learns that the send failed, not which vendor was involved, which trace id was assigned or what the gateway said. That last part is a habit worth keeping, because upstream error strings have a way of carrying account identifiers, and an error body is the easiest thing in the world to screenshot into a support ticket that ends up on a public forum.
Nothing else is exposed.
A template Android autofill can read
The template field is where the OTP message body is decided, and mobile autofill imposes real constraints on it. Android’s SMS Retriever needs the message to start with the <#> marker and end with your app’s 11-character hash string; the code should sit near the front as plain digits. iOS is looser — it scans for a short numeric run near words like “code” — but the same layout satisfies both.
curl -X POST https://api.infrai.cc/v1/sms/otp \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155552671",
"template": "<#> {code} is your Acme code. Never share it. abcd1234efg"
}'
{
"ok": true,
"data": { "request_id": "req_71bc9e02da", "sent": true },
"metadata": { "vendor": "tencent_sms", "cost_usd": 0.007475, "latency_ms": 588 }
}
Keep it to one numeric run.
A template that also carries a ticket number or a year gives the autofill heuristic two candidates, and it picks wrong often enough to matter. Some carriers reject promotional-looking bodies outright, which surfaces as SMS_CONTENT_REJECTED — no URLs and no marketing copy in a login message.
Users type local formats; your server fixes that
Mobile keyboards and country pickers produce 07700 900123, (415) 555-2671, and everything between. If a non-E.164 number reaches the gateway, the answer arrives through the vendor channel rather than as a validation error:
{
"ok": false,
"error": {
"code": "VENDOR_DOWN",
"http_status": 503,
"message": "recipient not in E.164 format: '07700900123'",
"retryable": true,
"trace_id": "trc_adbc33ec5d604212"
}
}
retryable: true on permanently bad input is a trap for the standard mobile networking wrapper, which retries 5xx on flaky connections by design. That’s why the proxy above rejects with a 400 before the request ever leaves your server — the regex is cheap insurance, and a proper library like libphonenumber is better still.
Timers that survive backgrounding
Don’t count seconds.
The single most common React Native bug in this flow is a setInterval countdown that keeps ticking while the app is suspended, or stops and never resumes — the user comes back from their SMS app to a button that says “Resend in 38s” forever. Return an absolute timestamp from the server instead, cooldownUntil above, store it, and recompute what’s left from Date.now() on every render and on every AppState transition back to active.
import { useEffect, useRef, useState } from "react";
import { AppState, Button, Text, TextInput, View } from "react-native";
const API = "https://your-backend.example.com";
export default function OtpScreen({ phone }) {
const [code, setCode] = useState("");
const [cooldownUntil, setCooldownUntil] = useState(0);
const [left, setLeft] = useState(0);
const busy = useRef(false);
useEffect(() => {
const tick = () => setLeft(Math.max(0, Math.ceil((cooldownUntil - Date.now()) / 1000)));
tick();
const t = setInterval(tick, 1000);
const sub = AppState.addEventListener("change", (s) => { if (s === "active") tick(); });
return () => { clearInterval(t); sub.remove(); };
}, [cooldownUntil]);
async function post(path, payload) {
if (busy.current) return null;
busy.current = true;
try {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, ...payload }),
});
return await res.json();
} finally {
busy.current = false;
}
}
return (
<View>
<TextInput
value={code}
onChangeText={setCode}
keyboardType="number-pad"
textContentType="oneTimeCode"
autoComplete="sms-otp"
maxLength={6}
/>
<Button title="Verify" onPress={async () => {
const r = await post("/otp/check", { code });
if (r?.verified) setCode("");
}} />
<Button
title={left > 0 ? `Resend in ${left}s` : "Resend code"}
disabled={left > 0}
onPress={async () => {
const r = await post("/otp/start", {});
if (r?.cooldownUntil) setCooldownUntil(r.cooldownUntil);
}}
/>
<Text>{left > 0 ? "Waiting for the code…" : "Didn't get it?"}</Text>
</View>
);
}
textContentType="oneTimeCode" covers iOS; autoComplete="sms-otp" covers Android. The busy ref stops a double-tap becoming two verify calls, which matters more than it looks — see the next section.
Every tap of Verify costs money
POST /v1/sms/verify is billed per call regardless of the answer. A wrong code, an expired code, a number with no outstanding challenge at all: all charged. That inverts the usual mobile instinct to make the button generously tappable.
When you’re debugging on a real handset it’s often easier to finish the login from a terminal than to fight the simulator’s clipboard:
curl -X POST https://api.infrai.cc/v1/sms/verify \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "to": "+14155552671", "code": "419283" }'
{
"ok": true,
"data": { "verified": false, "reason": "no_code_issued", "to": "+14155552671" },
"metadata": { "cost_usd": 0.005, "vendor": "infrai" }
}
That’s the response for a number with nothing pending — note that it still carries a cost. Keep an eye on the balance while you iterate:
curl -s https://api.infrai.cc/v1/account/balance \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '{balance: .data.balance_usd, runway_days: .data.runway_days, sms: .data.affordable_uses_hint["sms.send"]}'
Verified 2026-07-26, a send runs about $0.0075 and a verify $0.005, and new accounts carry $2 free credit — but check the call above rather than quoting me, since these move downward over time. The practical rule: five verify attempts per challenge, three sends per ten minutes, and a client-side debounce so an impatient double-tap doesn’t double the bill.
Where a mobile SDK is the better buy
| What you want | Infrai | A verification specialist |
|---|---|---|
| Send + verify over REST | Two routes, no SDK | Two routes, plus an SDK |
| Silent network (SIM) auth | Not supported | Twilio and Sinch both ship it |
| WhatsApp / voice fallback | Not supported | Part of the same verification object |
| Client-side rate limiting | You write it | Configured server-side for you |
| Email, storage, queues on the same key | Yes | Separate accounts |
If you want silent network authentication — verifying the SIM over the data connection with no code at all — Twilio and Sinch both ship mobile SDKs for it. Infrai doesn’t. Those are genuine reasons to pick a specialist, and if phone verification is the only external service your app needs, a specialist is probably less work.
The counter-argument is that mobile apps rarely need only one thing. The key that sends this code also sends the account email, stores the profile avatar, queues the push fan-out and reports what each tenant cost you — one credential, one invoice, plain REST at every call site. That’s the trade-off: no silent auth, but no fourth vendor either.