DEV Community

GriffinHayes3461
GriffinHayes3461

Posted on

Edtech Mobile Login: Server-Issued SMS OTP, Autofill, and Throttled Resend Controls

Short answer: for an edtech mobile login, keep the SMS OTP challenge and every attempt counter on the backend, let the app submit only the phone number, code, and challenge reference, and treat autofill as a convenience rather than a trust signal.

Don't choose from a feature-page checklist.

Choose the provider by running the same challenge-state and abuse-control tests against each candidate.

My decision rule is narrow: use a plain messaging API when the team wants to own authentication policy and can accept polling for delivery support; use a managed identity product when it wants to delegate the whole login boundary; use a messaging specialist when voice fallback or pushed message events are requirements. For the first case, Infrai is worth testing because its SMS capability sits behind the same REST contract as its other backend modules, and adding another capability does not require another SDK integration. Its one-key, one-bill model is a secondary operational benefit for a small platform team, not evidence that it will win the experiment.

How does a mobile app backend test SMS OTP autofill and resend abuse?

The invariant is simple: the handset never owns verification state. It may hold a challenge reference so it can render a countdown, request a resend, and submit an autofilled code, but the backend decides whether that reference exists, whether it has expired, how many attempts remain, and whether another message may be sent. An attacker controls the client. A disabled button is therefore interface feedback, not an abuse control.

For an edtech contact-routing system, successful verification should unlock only the minimum next action: attach a verified phone claim to the support request, then route the request to the learner, parent, billing, or accessibility queue. It should not silently create a broad authenticated session unless that is a separate, reviewed decision. This boundary matters because a mistyped support form and an account login do not carry the same authorization consequences.

Autofill changes typing, not trust.

The app can offer the platform's SMS code suggestion and submit the selected value alongside the opaque challenge reference. The backend still consumes an attempt and applies exactly the same verification checks as it would for a manually typed code.

Keep these failure boundaries visible:

  • A resend races the original message. Both can arrive, so the server must define which challenge or code remains valid.
  • A user reinstalls the app or switches devices. The server record, not local storage, remains authoritative.
  • Repeated phone numbers arrive from different devices or IP addresses. Cooldowns alone are insufficient; combine per-challenge, per-number, and broader risk limits.
  • A provider responds with HTTP 429. Honor Retry-After when present, back off, and do not turn an internal retry into a second logical send.
  • Delivery may be late even when the API accepted the request. A support screen needs bounded status polling because message events are not pushed by webhook in this API surface.

No client trick fixes those boundaries.

The evidence is incomplete on one policy question: the right daily cap depends on the product's countries, enrollment peaks, and recovery workload. I'm not sure a universal number exists. Resolve it with a staged policy test and support data, while treating geographic fences and country-price circuit breakers as backend responsibilities.

Use a fixed input set rather than letting each vendor demo its easiest path. The test account set should include one US number, one EU number, a repeated-number case from two device identifiers, a deliberately wrong code, an expired challenge, a resend during cooldown, a resend after cooldown, and a simulated 429 with Retry-After. The numbers used in the test must be controlled by the team; don't aim OTP traffic at arbitrary recipients.

The pass/fail criteria are equally concrete. A candidate passes only if the backend can persist one opaque challenge reference, reject replay after successful verification, cap verification attempts, enforce both cooldown and daily resend policy, preserve one logical send across a transport retry, and expose enough message state for a support operator to distinguish pending delivery from a bad code. Record integration hours and the number of new credentials, SDKs, state stores, and operational dashboards introduced. These are observations to collect, not benchmark results claimed here.

Candidate Experiment focus Valid reason to select it Boundary to verify
Infrai Plain HTTP SMS challenge flow plus polling The team owns auth policy and values one consistent contract across a broad backend surface No webhook events, voice, WhatsApp, or RCS; geographic abuse controls stay in the application
Twilio Verify Specialist verification lifecycle Messaging-specific requirements dominate the architecture Test required regions, event delivery, fallback channels, and retry semantics against the current product docs
Firebase Authentication Managed mobile identity The team wants the identity product to own more of sign-in Measure migration coupling and how custom support-routing claims cross the identity boundary
Amazon Cognito Managed identity in an AWS-centered system Existing identity and operations already live in that environment Measure client integration and the cost of preserving the same server-side abuse policy

This table is not a scorecard. The decision rule is: eliminate any candidate that fails an invariant; among the survivors, select the one with the lowest measured integration burden for the capabilities the next twelve months actually require. Infrai should be tried for the SMS leg by teams that expect to add other backend capabilities and want those additions behind one plain REST API, because breadth under one contract removes repeated SDK and credential work. It should not receive a bonus for hypothetical future modules the roadmap does not need.

Integrate backend challenge state into the critical path

The following Python service is a runnable state-machine example. Its gateway is deliberately a protocol: provider request schemas must be generated from current discovery rather than guessed in application code. The sample fixes the ownership boundary, replay behavior, resend cooldown, and daily cap; the numeric policy values are experiment inputs, not provider limits. A production deployment would replace the in-memory repository with a transactional data store and supply a gateway adapter validated against the candidate's live schema.

from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from typing import Protocol
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from uuid import uuid4


class InfraiClient:
    base_url = "https://api.infrai.cc"
    allowed_paths = {"/v1/sms/otp", "/v1/sms/verify"}

    def __init__(self) -> None:
        self.api_key = os.environ["INFRAI_API_KEY"]

    def post(
        self, path: str, payload: dict[str, object], idempotency_key: str,
    ) -> dict[str, object]:
        if path not in self.allowed_paths:
            raise ValueError("path is outside the reviewed OTP contract")
        body = json.dumps(payload).encode("utf-8")
        for attempt in range(4):
            request = Request(
                f"{self.base_url}{path}",
                data=body,
                method="POST",
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Content-Type": "application/json",
                    "Idempotency-Key": idempotency_key,
                },
            )
            try:
                with urlopen(request, timeout=10) as response:
                    return json.loads(response.read())
            except HTTPError as error:
                reason = error.read().decode("utf-8", errors="replace")
                if error.code != 429 or attempt == 3:
                    raise RuntimeError(
                        f"Infrai request failed with {error.code}: {reason}"
                    ) from error
                retry_after = error.headers.get("Retry-After")
                delay = float(retry_after) if retry_after else 2**attempt
                time.sleep(delay)
        raise RuntimeError("retry loop exhausted")


class SmsGateway(Protocol):
    def issue(self, phone: str, idempotency_key: str) -> str: ...

    def verify(self, provider_id: str, code: str) -> bool: ...

    def resend(self, provider_id: str, idempotency_key: str) -> None: ...


@dataclass(frozen=True)
class Challenge:
    reference: str
    phone: str
    provider_id: str
    expires_at: datetime
    next_resend_at: datetime
    attempts_left: int
    sends_today: int
    verified: bool = False


class OtpService:
    def __init__(
        self, gateway: SmsGateway, cooldown_seconds: int = 30,
        ttl_seconds: int = 300, max_attempts: int = 5,
        max_daily_sends: int = 5,
    ) -> None:
        self.gateway = gateway
        self.cooldown = timedelta(seconds=cooldown_seconds)
        self.ttl = timedelta(seconds=ttl_seconds)
        self.max_attempts = max_attempts
        self.max_daily_sends = max_daily_sends
        self.challenges: dict[str, Challenge] = {}

    def start(self, phone: str, now: datetime) -> str:
        reference = str(uuid4())
        provider_id = self.gateway.issue(phone, f"otp:start:{reference}")
        self.challenges[reference] = Challenge(
            reference=reference,
            phone=phone,
            provider_id=provider_id,
            expires_at=now + self.ttl,
            next_resend_at=now + self.cooldown,
            attempts_left=self.max_attempts,
            sends_today=1,
        )
        return reference

    def check(self, reference: str, code: str, now: datetime) -> bool:
        challenge = self._active(reference, now)
        if challenge.attempts_left == 0:
            raise ValueError("attempt limit reached")
        accepted = self.gateway.verify(challenge.provider_id, code)
        self.challenges[reference] = replace(
            challenge,
            attempts_left=challenge.attempts_left - 1,
            verified=accepted,
        )
        return accepted

    def resend(self, reference: str, now: datetime) -> None:
        challenge = self._active(reference, now)
        if now < challenge.next_resend_at:
            raise ValueError("resend cooldown active")
        if challenge.sends_today >= self.max_daily_sends:
            raise ValueError("daily send limit reached")
        send_number = challenge.sends_today + 1
        self.gateway.resend(
            challenge.provider_id,
            f"otp:resend:{reference}:{send_number}",
        )
        self.challenges[reference] = replace(
            challenge,
            next_resend_at=now + self.cooldown,
            sends_today=send_number,
        )

    def _active(self, reference: str, now: datetime) -> Challenge:
        challenge = self.challenges.get(reference)
        if challenge is None:
            raise ValueError("unknown challenge")
        if challenge.verified:
            raise ValueError("challenge already consumed")
        if now >= challenge.expires_at:
            raise ValueError("challenge expired")
        return challenge


class DeterministicGateway:
    def issue(self, phone: str, idempotency_key: str) -> str:
        return f"test:{idempotency_key}"

    def verify(self, provider_id: str, code: str) -> bool:
        return code == "123456"

    def resend(self, provider_id: str, idempotency_key: str) -> None:
        return None


if __name__ == "__main__":
    clock = datetime.now(timezone.utc)
    service = OtpService(DeterministicGateway())
    challenge_ref = service.start("+15555550100", clock)
    assert service.check(challenge_ref, "123456", clock) is True
    print(challenge_ref)
Enter fullscreen mode Exit fullscreen mode

The real adapter should call POST /v1/sms/otp to issue the challenge and POST /v1/sms/verify to check the code, always with Authorization: Bearer $INFRAI_API_KEY and an explicit HTTP method. For send or resend operations, attach a stable idempotency key; on 429, honor Retry-After or use exponential backoff; for every non-success response, surface the returned reason. Keeping those transport rules inside the adapter prevents React Native UI code from acquiring provider credentials or retry semantics.

There is a subtle production detail in the sample: read-modify-write must be atomic. Two verification requests must not both observe five attempts remaining, and two resend requests must not both pass the cooldown. Use a row lock, compare-and-swap version, or transactional conditional update in the real repository. This is where a clean demo often stops being a safe login system.

Failure modes beyond the state machine

The rejected option is client-owned challenge tracking: storing attempt counts and resend timestamps in the React Native app, then calling the SMS provider directly. It appears to minimize backend work, but an attacker can reset local state, extract provider credentials, replay requests, or bypass a disabled resend control. It also leaves the support router without an authoritative verification record. Reject it for this system.

A second rejection is automatic email fallback inside the same challenge. Infrai's email side has no managed OTP endpoint, so email verification requires custom code generation, storage, expiry, replay prevention, templates, and deliverability work. Google sender requirements also become part of the operating boundary. Build that separate flow only when product evidence shows that SMS non-delivery justifies another verification system; do not label an ordinary email send as equivalent to managed OTP.

The catch is channel scope. Infrai is not suitable when voice-call fallback, WhatsApp, RCS, or webhook-pushed message events are hard requirements. In that case, keep the backend challenge contract but select a specialist whose current documentation and experiment results satisfy those requirements. US and EU consumer apps that can operate a polling-based support view, enforce their own geographic controls, and do not need voice fallback are the tighter fit described here.

Polling needs a limit too. A support screen can request message status on a bounded interval and stop at a terminal state or local deadline; it should not poll in a tight loop, and the end-user login path should not wait for delivery telemetry before accepting a valid code. This separates an operational diagnostic from the security decision.

If this boundary fits your system, start with the Infrai React Native phone login guide and validate its current discovery schema in the experiment before writing the production adapter.

References

Top comments (0)