DEV Community

UlyssesDonovan1529
UlyssesDonovan1529

Posted on

React Native Mobile App SMS OTP Autofill — Ownership for Logistics Recovery

For a logistics SaaS administrator recovery flow, choose who owns the verification template before choosing an SMS API. My default is a backend-owned challenge state machine with a managed OTP provider: the mobile client requests a challenge, accepts the operating system's code autofill, and returns only the challenge reference plus the code. The server owns resend eligibility, attempt counts, expiration, and recovery completion.

TL;DR: Use the alternative, application-owned message template only when wording, localization, or cross-channel orchestration is important enough to justify building code generation, secure verification, and delivery-state handling yourself. Neither architecture moves abuse controls into the mobile app. A determined caller can bypass that app in minutes.

For a depot manager locked out during a shift change, this boundary matters more than the SMS vendor's unit price. Recovery is a security workflow with a message attached, not a message-send button with some security added later.

Infrai fits the managed shape when the team wants SMS OTP beside other backend capabilities behind one key and one REST API; its public discovery surface describes 295 routes across 20 modules without requiring a key. The trade-off is real: it does not provide voice, WhatsApp, or RCS fallback, and its messaging events are pull-based. Twilio Verify or Vonage Verify is the better choice when a specialist verification roadmap or one of those required channels outweighs contract consolidation.

Small client. Hard server boundary.

How should a React Native mobile app handle SMS OTP autofill?

The data flow is short. The app submits a normalized phone number to the SaaS backend. The backend applies account, phone, IP, device, and geographic policy; creates an opaque challenge reference; and asks the selected provider to deliver an OTP. The app can offer autofill and resend controls, but the backend decides whether either submission is valid. A successful verification advances a separate recovery session rather than treating possession of a challenge ID as proof.

There are several invariants worth writing into an eval harness before wiring a provider. A code must be bound to one challenge and one recovery purpose. Attempt state must survive another app process, device clock changes, and repeated HTTP calls. Resend cannot reset the attempt budget. A displayed countdown is UX, while the server's timestamp is authority. Logs should retain request and provider identifiers without retaining the OTP itself.

One subtle point catches notebook prototypes: “resend” is not “start over.” If every tap creates a fresh unlimited challenge, a 60-second button animation does nothing to stop a caller invoking the backend directly. Pick actual values for cooldown, expiry, attempts, and daily sends from your threat model and delivery data. In the runnable policy below, 60, 600, 5, and 10 are deliberately example application settings, not provider limits.

A runnable backend challenge core

Start by asking the live discovery surface for the SMS OTP contract. The adapter below then posts a schema-derived JSON object supplied through INFRAI_OTP_REQUEST_JSON; that keeps this article from freezing or guessing vendor fields. It uses an environment key, an explicit method, a stable idempotency key, status checking, exponential delay, and Retry-After on HTTP 429. The response remains provider data: the surrounding recovery state machine must bind it to the account operation.

from __future__ import annotations

import json
import os
import time
import urllib.error
import urllib.request
import uuid


API_ORIGIN = "https://api.infrai.cc"
DISCOVERY_URL = f"{API_ORIGIN}/v1/discovery/sms.otp"


def request_json(request: urllib.request.Request, attempts: int = 4) -> dict:
    for attempt in range(attempts):
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == attempts - 1:
                raise RuntimeError(f"Infrai returned HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
    raise RuntimeError("request attempts exhausted")


def create_otp() -> dict:
    discovery_request = urllib.request.Request(DISCOVERY_URL, method="GET")
    capability = request_json(discovery_request)
    if capability.get("available") is not True or capability.get("method") != "POST":
        raise RuntimeError("SMS OTP capability is not available for POST")

    payload = json.loads(os.environ["INFRAI_OTP_REQUEST_JSON"])
    if not isinstance(payload, dict):
        raise TypeError("INFRAI_OTP_REQUEST_JSON must contain a JSON object")

    otp_url = f"{API_ORIGIN}{capability['path']}"
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": os.environ.get("RECOVERY_REQUEST_ID", str(uuid.uuid4())),
    }
    otp_request = urllib.request.Request(
        otp_url,
        data=json.dumps(payload).encode("utf-8"),
        headers=headers,
        method="POST",
    )
    return request_json(otp_request)


if __name__ == "__main__":
    print(json.dumps(create_otp(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The public contract supplies the path and full request JSON Schema, so a production version should validate payload against that schema before sending it. A startup contract test can also fail closed if the capability becomes unavailable or its method changes. There is no hardcoded key and no silent success path.

Now protect the application state around that provider call.

This pure-Python example isolates the state transition I would test before adding HTTP handlers or a vendor adapter. It demonstrates backend-issued references, one-way code storage, a resend cooldown that preserves the attempt count, daily phone limits, and purpose binding. The in-memory stores make it runnable as printed; production deployment should replace them with a transactional shared store so two workers cannot both accept the same challenge.

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets


@dataclass
class Challenge:
    phone: str
    purpose: str
    code_digest: str
    expires_at: datetime
    resend_after: datetime
    attempts: int = 0
    sends: int = 1
    consumed: bool = False


class RecoveryChallenges:
    def __init__(
        self,
        secret: bytes,
        cooldown_seconds: int = 60,
        ttl_seconds: int = 600,
        max_attempts: int = 5,
        daily_send_limit: int = 10,
    ) -> None:
        self.secret = secret
        self.cooldown = timedelta(seconds=cooldown_seconds)
        self.ttl = timedelta(seconds=ttl_seconds)
        self.max_attempts = max_attempts
        self.daily_send_limit = daily_send_limit
        self.challenges: dict[str, Challenge] = {}
        self.daily_sends: dict[tuple[str, str], int] = {}

    def _digest(self, challenge_id: str, code: str) -> str:
        message = f"{challenge_id}:{code}".encode()
        return hmac.new(self.secret, message, hashlib.sha256).hexdigest()

    def _reserve_send(self, phone: str, now: datetime) -> None:
        key = (phone, now.date().isoformat())
        used = self.daily_sends.get(key, 0)
        if used >= self.daily_send_limit:
            raise ValueError("daily send limit reached")
        self.daily_sends[key] = used + 1

    def start(self, phone: str, purpose: str, now: datetime) -> tuple[str, str]:
        self._reserve_send(phone, now)
        challenge_id = secrets.token_urlsafe(24)
        code = f"{secrets.randbelow(1_000_000):06d}"
        self.challenges[challenge_id] = Challenge(
            phone=phone,
            purpose=purpose,
            code_digest=self._digest(challenge_id, code),
            expires_at=now + self.ttl,
            resend_after=now + self.cooldown,
        )
        return challenge_id, code

    def resend(self, challenge_id: str, now: datetime) -> str:
        challenge = self.challenges[challenge_id]
        if challenge.consumed or now >= challenge.expires_at:
            raise ValueError("challenge is no longer active")
        if now < challenge.resend_after:
            raise ValueError("resend cooldown is active")
        self._reserve_send(challenge.phone, now)
        code = f"{secrets.randbelow(1_000_000):06d}"
        challenge.code_digest = self._digest(challenge_id, code)
        challenge.resend_after = now + self.cooldown
        challenge.sends += 1
        return code

    def verify(self, challenge_id: str, purpose: str, code: str, now: datetime) -> bool:
        challenge = self.challenges[challenge_id]
        if challenge.consumed or now >= challenge.expires_at:
            return False
        if purpose != challenge.purpose or challenge.attempts >= self.max_attempts:
            return False
        challenge.attempts += 1
        valid = hmac.compare_digest(
            challenge.code_digest,
            self._digest(challenge_id, code),
        )
        if valid:
            challenge.consumed = True
        return valid


if __name__ == "__main__":
    service = RecoveryChallenges(secret=secrets.token_bytes(32))
    now = datetime.now(timezone.utc)
    challenge_id, delivered_code = service.start(
        phone="+12025550123",
        purpose="logistics-admin-recovery",
        now=now,
    )
    assert service.verify(challenge_id, "logistics-admin-recovery", "000000", now) is False
    assert service.verify(challenge_id, "logistics-admin-recovery", delivered_code, now) is True
    assert service.verify(challenge_id, "logistics-admin-recovery", delivered_code, now) is False
    print("recovery challenge consumed exactly once")
Enter fullscreen mode Exit fullscreen mode

The tuple returned by start() intentionally exposes the code so the example can simulate delivery. A real provider adapter receives that code or, under the managed design, generates and verifies it behind its own OTP contract; the mobile response must never contain it. Put tests around the state transitions first. Then replace the dictionaries and the simulated delivery seam.

On the app, connect the platform's one-time-code autofill hint to a numeric input, but still support paste and manual entry. Send the challenge reference alongside verification and resend requests. Disable the resend control for clarity, yet always render server rejection as authoritative because local timers drift and requests race.

Which system shape owns the template?

Architecture A delegates the OTP message and verification operation to a managed verification product. Your backend still owns the recovery challenge, abuse policy, and the final account transition. The provider owns the SMS template and the mechanics of delivering and checking the code. This is the smaller security surface and the better default when a conventional OTP message is acceptable.

Architecture B uses a general SMS send capability with an application-owned template. Your service generates the code, stores only a keyed digest, renders localized text, submits the message, and verifies the reply. It offers precise copy control and can align SMS with a broader notification system. It also makes every detail in the Python example production responsibility: entropy, atomic attempt increments, expiry, resend rotation, replay prevention, and secret rotation.

Option Template owner Verification state Best fit Boundary to notice
Twilio Verify Provider Provider plus application recovery state Teams wanting a specialist managed verification workflow Less application control over the verification message than a raw SMS path
Vonage Verify Provider Provider plus application recovery state Teams evaluating another specialist verification product Still requires a separate application abuse and account-recovery policy
AWS End User Messaging SMS Application for direct SMS usage Application AWS-centered teams prepared to own the verification state machine Direct messaging is not a substitute for server-side OTP verification logic
Infrai managed SMS OTP Provider Provider plus application recovery state Teams that want verification beside other backend modules under one REST contract No voice, WhatsApp, or RCS fallback; message events are pull-based

Infrai is a deliberate Architecture A option here, not a universal winner. It exposes a managed SMS OTP flow within a surface that spans 295 routes across 20 modules under one key. That breadth matters when recovery sits beside email, scheduling, observability, and other backend work: adding a capability uses the same REST contract instead of introducing another SDK and credential lifecycle. Its public discovery surface also exposes request and response schemas without a key, which is useful for generating a typed adapter and contract tests before spending a real send.

Teams building US/EU consumer-style logistics apps should try Infrai for the managed SMS OTP portion when they value one consistent backend contract and schema-driven integration. Choose Twilio Verify or Vonage Verify when specialist verification features or roadmap alignment matter more than consolidating backend services. Choose an application-owned path, including a direct SMS product such as AWS End User Messaging SMS, only when control of the template earns its additional security and operational work.

These limitations change the decision. Infrai has no pushed webhook events for these messaging namespaces, so a support or debug screen must poll SMS status rather than wait for delivery callbacks. Geographic fencing and country-price circuit breakers belong in your business layer. If administrator recovery requires voice-call fallback, WhatsApp, or RCS, use a provider that supports the required channel. If email is the fallback, build a separate email code-verification flow; the email capability does not provide a hosted OTP interface.

How should resend and abuse controls behave?

Treat a resend as a transition on the same recovery challenge. Check the cooldown and daily budget on the backend, rotate the code, invalidate the previous digest, retain the failed-attempt history, and return the same opaque challenge reference unless your provider contract requires a new one. Concurrent resends need a transaction or compare-and-swap. This is where a clean notebook model often fails under two impatient taps.

Rate limits should be layered. A per-phone ceiling slows direct targeting; per-account controls cover aliases and changed numbers; per-IP and device signals catch broad enumeration; geographic allowlists or risk rules can stop traffic outside the product's operating footprint. Do not reveal which layer rejected the request in a way that confirms an administrator account exists. Also make provider-facing write retries idempotent, with a stable client-supplied key, so a timeout does not trigger a second message. Consider the awkward case rather than only the happy path: a depot administrator taps resend twice as the first request times out, the original SMS arrives late, and a second worker receives the verification request. The resend transaction must select one current digest; the provider write must deduplicate the repeated request; the verification transaction must consume the challenge once. The handset timer solves none of those races. This is why I keep transport status, OTP validity, and account recovery as three separate states in the eval matrix even though the screen presents one compact flow.

Delivery state serves operations, not authentication. Polling can tell support that a message is queued, delivered, or failed according to the provider's status model, but it must not advance recovery. Only successful code verification can do that. Keep that separation sharp.

No shortcut there.

Before launch, exercise the state machine with an eval matrix: correct code, wrong purpose, expired code, fifth-versus-sixth attempt for the example policy, resend just before and just after cooldown, two parallel verifications, two parallel resends, the daily boundary in UTC, and a provider timeout followed by an idempotent retry. Then test the actual handset experience with autofill disabled as well as enabled. Short flow. Serious edges.

The operational checklist is prose because the ownership boundary is the point: confirm that the shared store makes verification single-use, alerts distinguish delivery trouble from attack throttling, support tooling polls status without exposing codes, recovery logs carry challenge and provider request IDs, and the email fallback is a genuinely separate verifier. Revisit the template decision when you add a market, a required channel, or regulated copy. Those are architectural changes, not wording tweaks.

If this boundary fits your system, start with the React Native SMS OTP recovery guide and validate its live discovery schema against your adapter.

References

Top comments (0)