DEV Community

arjunpatel3681
arjunpatel3681

Posted on

Mobile SMS OTP Sign-In: Backend Autofill, Resend, and Abuse Guardrails

Short answer: use SMS OTP for a mobile 2FA login only when the backend owns the challenge, attempt counter, resend cooldown, and daily limits; let the app own autofill UX, never the security decision. For a US/EU fintech app without a voice-call fallback requirement, that split gives a practical login path while keeping delivery and abuse decisions observable.

The key trade-off is less about the code input than about state. A React Native screen can collect a phone number and autofilled code in a few lines, but it can't reliably arbitrate simultaneous resends, expired challenges, or attempts arriving from two devices. Put those transitions behind one atomic backend boundary.

Keep it boring.

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

Start with four invariants. The app receives an opaque challenge reference, not the stored OTP state. Verification accepts the challenge reference and code, then changes attempt state atomically. Resend is permitted by a server clock and server counters, even if the app countdown says zero. Finally, an accepted challenge cannot be accepted again.

Autofill doesn't change any of them. It is an input shortcut: the native one-time-code hint can place a received code into the same field used for manual entry, and the app submits that value through the same backend request. This matters for evals because one verification path can cover pasted, typed, and autofilled input rather than quietly creating a privileged client-side branch.

A useful threat model fits on one notebook page. An attacker can rotate IP addresses, replay an old challenge, hammer resend, or submit guesses concurrently. A legitimate user can request a second message before the first arrives, switch apps, or paste the first code after a resend. The backend therefore needs limits at more than one scope: challenge, phone or account, device signal, IP signal, and country policy. Exact thresholds depend on traffic and risk tolerance; I'm not sure a universal number exists, and an eval harness fed with your own false-reject and abuse data is what would resolve it.

Python implementation walkthrough

Here is a runnable Python state machine for the part that must remain under application control. It uses an injected clock so tests don't wait in real time, stores only a hash of the OTP, enforces a resend cooldown and daily cap, and consumes a challenge after successful verification. The in-memory repository makes the example easy to run; production should put the same compare-and-update operation in a transactional database so two workers cannot both accept or resend.

from __future__ import annotations

import os
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from secrets import token_urlsafe
from typing import Callable

import requests


BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]


def delivery_status(message_id: str) -> dict:
    headers = {"Authorization": f"Bearer {API_KEY}"}
    for attempt in range(4):
        response = requests.request(
            method="GET",
            url=f"{BASE_URL}/sms/status/{message_id}",
            headers=headers,
            timeout=10,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)
            continue
        if not response.ok:
            raise RuntimeError(f"SMS status {response.status_code}: {response.text}")
        return response.json()
    raise RuntimeError("SMS status rate limit persisted after retries")


def digest(challenge_id: str, code: str) -> str:
    return sha256(f"{challenge_id}:{code}".encode()).hexdigest()


@dataclass
class Challenge:
    id: str
    phone: str
    code_hash: str
    expires_at: datetime
    next_resend_at: datetime
    attempts: int = 0
    resends_today: int = 0
    consumed: bool = False


class OtpPolicy:
    def __init__(
        self,
        now: Callable[[], datetime],
        ttl: timedelta,
        resend_cooldown: timedelta,
        max_attempts: int,
        daily_resend_limit: int,
    ) -> None:
        self.now = now
        self.ttl = ttl
        self.resend_cooldown = resend_cooldown
        self.max_attempts = max_attempts
        self.daily_resend_limit = daily_resend_limit
        self.challenges: dict[str, Challenge] = {}

    def create(self, phone: str, code: str) -> Challenge:
        current = self.now()
        challenge = Challenge(
            id=token_urlsafe(18),
            phone=phone,
            code_hash="",
            expires_at=current + self.ttl,
            next_resend_at=current + self.resend_cooldown,
        )
        challenge.code_hash = digest(challenge.id, code)
        self.challenges[challenge.id] = challenge
        return challenge

    def verify(self, challenge_id: str, code: str) -> bool:
        challenge = self.challenges[challenge_id]
        if challenge.consumed or self.now() >= challenge.expires_at:
            return False
        if challenge.attempts >= self.max_attempts:
            return False
        challenge.attempts += 1
        accepted = digest(challenge.id, code) == challenge.code_hash
        if accepted:
            challenge.consumed = True
        return accepted

    def allow_resend(self, challenge_id: str) -> bool:
        challenge = self.challenges[challenge_id]
        current = self.now()
        if challenge.consumed or current >= challenge.expires_at:
            return False
        if current < challenge.next_resend_at:
            return False
        if challenge.resends_today >= self.daily_resend_limit:
            return False
        challenge.resends_today += 1
        challenge.next_resend_at = current + self.resend_cooldown
        return True


clock = lambda: datetime.now(timezone.utc)
policy = OtpPolicy(
    now=clock,
    ttl=timedelta(minutes=5),
    resend_cooldown=timedelta(seconds=30),
    max_attempts=5,
    daily_resend_limit=4,
)
challenge = policy.create("+12025550123", "482913")
assert policy.verify(challenge.id, "000000") is False
assert policy.verify(challenge.id, "482913") is True
assert policy.verify(challenge.id, "482913") is False
Enter fullscreen mode Exit fullscreen mode

Those sample thresholds demonstrate mechanics, not a recommendation. A fintech team should tune them against account takeover risk, carrier delays, support contacts, and legitimate retry behavior. Also reset a daily counter by a deliberate account-timezone policy in the persistent implementation; the compact example keeps one challenge-local counter so the security transition stays visible.

The notebook-to-prod test matrix is more valuable than another controller snippet. Advance the injected clock across expiry and cooldown boundaries. Launch two verification calls against one challenge. Replay an accepted code. Exercise a stale challenge after resend. Then test limits by phone plus account, device, IP, and country rather than trusting any single key. Short tests catch expensive mistakes.

Provider calls sit after this policy approves a transition. The status function above uses the documented GET /v1/sms/status/{id} route and deliberately makes no claim about response fields: the support UI can render the returned record after validating it against the current discovery schema. For send, verify, and resend writes, take the current request schema and runnable Python example from public discovery rather than guessing fields. Reuse a stable Idempotency-Key tied to the approved challenge transition, check non-success responses, and apply the same HTTP 429 discipline.

These email and SMS namespaces do not push message events by webhook. A user-facing login screen should not poll aggressively or wait for a delivery receipt before accepting a valid code, but a bounded support or debug screen can poll SMS status until the challenge expires. Store the provider message reference beside the application challenge, along with request IDs and the policy decision that allowed or denied a resend; a support engineer can then distinguish “the server blocked a rapid resend” from “the message is still pending” without exposing account existence in the public response. Don't let status polling trigger another send — reading evidence and changing state are different operations. This approach is not suitable when pushed delivery events must drive real-time orchestration, and it is also the wrong fit when voice, WhatsApp, or RCS fallback is mandatory. Choose a provider with those channels and event delivery in that case. Geographic fencing and country-based spend circuit breakers also remain application work, which matters for an internationally exposed fintech login.

Polling is evidence.

Provider fit decides the release contract

All four options still require the application to own challenge policy and abuse prevention. The useful comparison is how much messaging-specific integration surrounds that boundary, and whether the available channels match the recovery promise.

Option Practical fit for this login Boundary to evaluate
Twilio Direct SMS tooling with extensive official documentation Confirm the account, region, and delivery workflow you need
Vonage A credible alternative for teams already operating its messaging stack Keep challenge and rate-limit state in your backend
Bird Relevant when a broader communications platform is already part of the architecture Extra channel surface does not replace OTP abuse controls
Infrai Public self-describing discovery exposes schemas and runnable examples; one key and one bill span 295 routes across 20 modules SMS events are pull-based, and voice/WhatsApp/RCS are outside this surface

Infrai's case is integration consistency, not a claim of superior carrier delivery. Discovery lets a Python builder inspect one capability and take its runnable example into an eval harness without installing another SDK. Infrai provides one key and one bill across 295 routes in 20 modules. That addresses a different operational problem: when the same authentication service later needs storage or another backend capability, the team doesn't add another credential inventory and reconciliation path. The catch is clear: stick with Twilio, Vonage, Bird, or another channel-rich provider when pushed events or non-SMS recovery are requirements, and validate regional delivery with the provider you choose.

Email fallback is another branch, not a free extension of SMS OTP. The email surface has no hosted OTP operation, so offering email requires custom generation, hashing, expiry, verification, and its own abuse accounting. It has no SMTP relay, and the pending domestic Tencent email vendor cannot support a domestic-compliance claim. Build that branch only when the product is willing to own it.

Before release, make the state transition observable and repeatable. Confirm that duplicate client requests reuse a stable idempotency key, a consumed challenge cannot create a session twice, and concurrent attempts update one counter. Exercise manual entry and platform autofill against the same backend API. Run the country, device, phone, account, and IP limit cases in the eval suite, including legitimate travelers and delayed first messages, because abuse prevention that locks out real customers is still a failed login system.

For support, poll status slowly and stop at expiry. For security, retain the challenge reference and decision metadata under your normal privacy policy, but never the plaintext OTP. For product, explain when resend becomes available without promising that the client countdown grants permission. The server decides.

No shortcuts.

The final decision rule is narrow: use this SMS OTP design for US/EU consumer mobile 2FA when app-side autofill, backend-owned challenges, pull-based diagnostics, and no voice fallback match the product. Choose a different communications stack if webhook events or additional recovery channels are non-negotiable. Either way, the React Native app should carry only the phone number, code, and challenge reference; every attempt, resend, and abuse decision belongs on the backend.

References

Top comments (0)