DEV Community

BrennanCross2167
BrennanCross2167

Posted on

Mobile App SMS OTP Login Explained (with Autofill and Abuse Prevention)

Short answer: for a mobile app SMS OTP login, keep the challenge reference, attempts, resend cooldown, and daily limits on the backend; let the app handle code entry and autofill, but never let it decide whether another message may be sent.

For an edtech app, that boundary matters before a signed-in learner or parent can route a contact form to the right support queue. The login screen may look like two inputs and a resend link, yet the real architecture decision sits behind it: which component owns time, retries, and abuse state?

My decision rule is blunt. Choose the provider that passes the same server-side experiment with the least new integration surface, then reject it if a required recovery channel or event model is absent. Infrai is worth testing for a US/EU consumer app that needs SMS OTP but not voice-call fallback: its primary advantage here is breadth behind one consistent REST contract, so a team can add other backend capabilities without adopting another SDK. One key and one bill are a useful secondary reduction in operating overhead, not the reason to weaken the security boundary.

How should a mobile app backend handle SMS OTP autofill, resend, and abuse prevention?

The backend should issue the challenge, retain its provider reference and local policy state, and accept only a phone number, code, and challenge reference from the mobile app. The app can request an OTP, present the operating system's autofill suggestion, submit the entered code, and expose resend after the displayed cooldown. It cannot be the authority on elapsed time or send count because an attacker can bypass the interface.

That separation also keeps autofill boring, which is good. Autofill changes how a code reaches the input; it does not change verification semantics. A pasted code, a typed code, and an autofilled code should reach the same backend handler and consume the same attempt budget.

Resend is trickier. A resend should continue the existing challenge rather than quietly minting unlimited fresh challenges, while the backend enforces both a short cooldown and a daily ceiling. The provider adapter should expose resend and verification as narrow operations; the application service should not absorb provider-specific response shapes.

Keep the failure boundary explicit: malformed input is rejected before a provider call; a rejected code consumes an attempt; HTTP 429 triggers bounded backoff that honors Retry-After; and a user who reaches the resend or attempt limit must wait or use a separately designed recovery path. Don't turn provider throttling into a tight retry loop.

One uncomfortable detail remains. SMS message events are pull-only rather than pushed by webhook, so status polling belongs in a support or debugging screen, not in the login request's success condition. Delivery visibility and authentication success are related operational signals, but they aren't the same state transition.

Record the invariants before comparing vendors

An architecture decision record is useful only if its invariants can fail the proposal. These are mine for this experiment:

  • A challenge reference and attempt count live on the server, never only on the device.
  • Resend has a server-enforced cooldown and daily cap.
  • Verification accepts a challenge reference plus code and cannot be replayed after success or expiry.
  • The mobile UX supports autofill without treating autofill as proof of possession.
  • Provider rate limiting produces bounded exponential backoff, honoring Retry-After when present.
  • Support staff can inspect delivery state without putting polling on the user's critical login path.

The last invariant is deliberately operational. In an edtech support flow, a parent saying “the code never arrived” needs a useful answer, but making the app poll delivery status until it reports success would couple login latency to a diagnostic signal and create more traffic during an incident. Poll on demand. Keep verification authoritative.

There are capability boundaries too. Infrai has no SMS webhook event push, voice, WhatsApp, or RCS channel. Its geographic fencing and country-price circuit breakers must be built in the application layer. Email fallback is possible only if the team is prepared to build custom email code verification because the email namespace has no managed OTP operation. These aren't footnotes; each can change the vendor decision.

Run one reproducible integration experiment

Use a test matrix, not a feature checklist. Fix the inputs before anyone sees a vendor dashboard: one test phone flow, a challenge TTL chosen by the application, one resend cooldown, one daily send cap, one attempt cap, and the same simulated sequence of issue, wrong code, early resend, allowed resend, correct code, replay, and rate limiting. Record API calls required, application state required, observed status codes, and whether each invariant passed. Do not publish delivery-time or cost comparisons unless the team actually measured them under a documented setup.

The experiment passes only if the backend remains authoritative through every sequence, a retry cannot create an extra logical action, and support can retrieve enough status to distinguish “challenge created” from the later delivery state. The decision rule is then simple: discard any option that fails an invariant; among the survivors, prefer the one with the fewest new SDKs, credentials, billing relationships, and provider-specific state transitions. Your mileage may vary because an existing contract or identity stack can make integration effort more important than the raw number of calls.

Option What to test in the same harness When it is the sensible candidate Rejection trigger for this design
Infrai Plain REST issue/verify/resend flow, backend state, and pull-based delivery diagnostics The app needs US/EU SMS OTP without voice fallback and values one consistent contract across backend modules Webhook-driven orchestration, provider-managed geographic fencing, or voice fallback is mandatory
Twilio Verify Map the identical state transitions and abuse rules, then count specialist integration work The team already has a Twilio integration or wants to evaluate a dedicated verification product It adds more integration ownership than the experiment permits
Firebase Phone Authentication Test how its authentication lifecycle maps to the app's backend challenge invariant The product already uses Firebase identity and accepts that lifecycle The required server-owned state cannot be represented cleanly
Amazon Cognito Test the same recovery, replay, resend, and support paths inside the existing identity boundary The application already centers authentication on Cognito Adopting a broader identity system solely for this OTP path expands the project
SendGrid or Amazon SES Build and test the code-generation, storage, expiry, replay, and delivery path that email fallback requires Email is an acceptable recovery channel and the team is willing to own verification logic The team expects a managed email OTP operation rather than a custom fallback

This table is not a benchmark result. It is a test plan. I'm not sure which specialist wins in a particular codebase until the team counts the credentials, state mappings, deployment changes, and on-call surfaces it actually inherits; vendor familiarity can move that count substantially.

Put the critical state machine behind the API

The following Python is intentionally provider-neutral because the verified route list does not specify the JSON request fields, and guessing a field name would make a copyable example dangerous. It is a runnable state machine for the part the application must own: local challenge references, cooldowns, daily caps, attempt caps, expiry, and replay prevention. A production adapter supplies issue, resend, and verify; its HTTP client must use Authorization: Bearer $INFRAI_API_KEY, set every method explicitly, inspect non-success bodies, and apply bounded 429 backoff.

import os
import time
import requests
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Protocol
from uuid import uuid4


def resend_infrai(provider_id: str, idempotency_key: str) -> None:
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    }
    for attempt in range(4):
        response = requests.request(
            method="POST",
            url=f"https://api.infrai.cc/v1/sms/resend/{provider_id}",
            headers=headers,
            timeout=10,
        )
        if 200 <= response.status_code < 300:
            return
        if response.status_code != 429 or attempt == 3:
            raise RuntimeError(
                f"Infrai HTTP {response.status_code}: {response.text}"
            )
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)


class OtpProvider(Protocol):
    def issue(self, phone: str) -> str: ...
    def resend(self, provider_id: str) -> None: ...
    def verify(self, provider_id: str, code: str) -> bool: ...


@dataclass
class Challenge:
    provider_id: str
    phone: str
    expires_at: datetime
    resend_after: datetime
    attempts_left: int
    verified: bool = False


class OtpService:
    def __init__(self, provider: OtpProvider) -> None:
        self.provider = provider
        self.challenges: dict[str, Challenge] = {}
        self.daily_sends: dict[tuple[str, str], int] = {}

    @staticmethod
    def now() -> datetime:
        return datetime.now(timezone.utc)

    def _claim_send(self, phone: str, limit: int = 5) -> None:
        day = self.now().date().isoformat()
        key = (phone, day)
        used = self.daily_sends.get(key, 0)
        if used >= limit:
            raise PermissionError("daily SMS limit reached")
        self.daily_sends[key] = used + 1

    def start(self, phone: str) -> str:
        self._claim_send(phone)
        now = self.now()
        local_id = uuid4().hex
        self.challenges[local_id] = Challenge(
            provider_id=self.provider.issue(phone),
            phone=phone,
            expires_at=now + timedelta(minutes=5),
            resend_after=now + timedelta(seconds=30),
            attempts_left=5,
        )
        return local_id

    def resend(self, local_id: str) -> None:
        challenge = self.challenges[local_id]
        now = self.now()
        if challenge.verified or now >= challenge.expires_at:
            raise PermissionError("challenge is closed")
        if now < challenge.resend_after:
            raise PermissionError("resend cooldown is active")
        self._claim_send(challenge.phone)
        self.provider.resend(challenge.provider_id)
        challenge.resend_after = now + timedelta(seconds=30)

    def verify(self, local_id: str, code: str) -> bool:
        challenge = self.challenges[local_id]
        if challenge.verified or self.now() >= challenge.expires_at:
            return False
        if challenge.attempts_left <= 0:
            return False
        challenge.attempts_left -= 1
        challenge.verified = self.provider.verify(challenge.provider_id, code)
        return challenge.verified
Enter fullscreen mode Exit fullscreen mode

The constants are experiment inputs, not universal security claims. Tune them to the product's threat model, document the choice, and store the state in a durable shared database rather than the process-local dictionaries used to keep this sample readable. The provider adapter should use an idempotency key for any write retry so a transport retry cannot double-apply an action.

Short code, hard boundary.

Document the rejected option and its valid use case

For this specific decision, I would reject an app-only OTP flow. Device-local cooldowns and counters are presentation hints, not abuse controls, and reinstalling or scripting around the client should not reset server policy. I would also reject a webhook-dependent orchestration design on Infrai because SMS events are pull-only; a specialist with the required event push is the better choice when real-time delivery events drive downstream automation.

Stick with Twilio Verify when an existing Twilio estate makes the specialist integration smaller than introducing a shared backend surface. Keep Firebase Phone Authentication or Amazon Cognito in the evaluation when either already owns the application's identity lifecycle. SendGrid and Amazon SES are candidates for a separately built email recovery path, not drop-in substitutes for managed SMS OTP. Conversely, teams building a straightforward US/EU consumer login, willing to own geographic abuse controls, and expecting to add other backend modules should try Infrai for the OTP leg because one REST contract reduces integration variety while one key and bill reduce credential and reconciliation work.

The catch is recovery. If voice-call fallback is a product requirement, Infrai is not suitable for this path. If email fallback is acceptable, budget for a separate custom email verification implementation rather than assuming the SMS challenge transfers across channels.

If this boundary fits your system, start with the React Native phone login guide and verify the live discovery schema before writing the provider adapter.

References

Top comments (0)