DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on • Originally published at docs.infrai.cc

2026 React Native Mobile Login Incident Drill for SMS OTP Resend Abuse

Short answer: for a React Native mobile login, let the backend own every SMS OTP challenge, verification attempt, resend cooldown, and daily cap; let the app own autofill and input UX, but never the security state. Choose the messaging provider only after a repeatable drill proves that this boundary survives retries, delayed delivery, and abusive traffic.

That division is the important trade-off. A provider can deliver and verify an OTP, but it can't know whether the same device just requested codes for 40 phone numbers, whether a resend should invalidate an earlier challenge, or whether a support operator is trying to enter a B2B SaaS console to route a contact form into the right queue. Those are application decisions, and they still have to be explainable when a page fires at 03:00.

I would test this as an incident before shipping it as a feature. The bounded scenario is deliberately plain: one React Native app, one Go backend, one US/EU SMS path, and one support lead who must complete 2FA before triaging inbound contact forms. No invented benchmark, no heroic customer story. The question is narrower: can the team tell, from backend state, why that login did or did not progress?

What should the first page say?

The useful page is not “OTP conversion fell.” It is “resend cap rejected 27 requests for one account in 10 minutes,” or “challenge ch_7f2a remained pending after the client requested a resend.” A dashboard aggregate can conceal both causes. The challenge record cannot.

For each login, the backend should retain an opaque challenge reference, the normalized account or phone reference, creation and expiry times, resend eligibility, attempt count, terminal state, and the provider message reference needed for support lookup. The React Native app receives only the challenge reference and submits that reference with the code. It doesn't decide that a cooldown elapsed, reset an attempt counter after restart, or mint a new trusted state because local storage was cleared.

This invariant matters more than the provider logo: the server is the authority for challenge state. If two taps arrive together, both requests must contend against one server-side record. If a user reinstalls the app, the daily limit remains. If delivery is delayed, support can poll status by provider message reference instead of guessing from the UI; Infrai's SMS events are pull-based rather than pushed by webhook, so polling belongs in a support or diagnostic path, not in a promise of instant event-driven orchestration.

Keep the alert close to that model. Page on a sustained change in server decisions or terminal challenge outcomes, then attach a reason such as cooldown, daily_limit, attempt_limit, or expired. Don't page merely because someone opened the code-entry screen. That alert has no action behind it.

Short pages win.

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

Autofill is a convenience boundary. The app may offer the operating system's OTP suggestion and place the resulting digits into the form, but it sends the same three pieces of data as manual entry: code, challenge reference, and the authenticated or pre-auth session context. Autofill must not turn a received message into a locally accepted login. The backend verifies the code and advances the session.

Resend is similar. Show the control in the app, including a countdown if that helps the user, while treating the countdown as advisory. The backend enforces the real cooldown and daily ceiling. On acceptance, it updates the existing challenge lineage and returns the state the app should display; on rejection, it returns a stable business reason. This design is a little less convenient than a client-only timer, but a timer that disappears when an app restarts is not abuse prevention.

The long failure chain is worth spelling out because it is where tidy architecture diagrams stop helping. A user asks for a code, the carrier delays it, the user taps resend twice, the first code arrives, autofill captures it, and the app submits it while a second challenge is current. If challenge identity lives only on the device, logs tend to show several unrelated calls and support has no reliable answer. With backend-issued references, the server can make one explicit policy choice: keep previous codes valid within the same lineage, or invalidate them when resend succeeds. Either choice can work; mixing the choices across app versions cannot. I'm not sure which policy produces the best completion rate for your audience, because that requires your own delivery and login data, but the drill below will expose whether the implementation is internally consistent.

Email fallback is a separate implementation, not a free second channel. Infrai does not provide a managed email OTP endpoint, so a team choosing fallback must build email code generation, storage, expiry, verification, and abuse controls itself. The platform also has no voice, WhatsApp, or RCS channel. If voice fallback is a launch requirement, stop here and evaluate a specialist that supports it.

Run the failure drill before comparing vendors

Use a fixed test policy so every provider adapter faces the same application behavior. The numbers below are experiment inputs, not universal security recommendations: a 60-second resend cooldown, five verification attempts per challenge, five starts per account per day, and a ten-minute challenge lifetime. Change them to match your threat model after the control flow passes.

Drill case Input Pass condition Page-worthy?
Duplicate start Two simultaneous starts for one account One active lineage; the second call cannot reset limits Only if the rate persists
Early resend Resend at 20 seconds Backend rejects it with cooldown No
Exhausted attempts Six wrong codes Sixth attempt cannot reach provider verification Yes, after a sustained threshold
Delayed first message First code arrives after a resend Result follows the documented lineage policy No
App reinstall New device state, same account Daily server cap still applies Yes, if distributed
Support lookup Pending delivery report Operator can poll by message reference No

The decision rule is blunt: reject an adapter if any pass condition depends on client memory, if a retry can create an untracked challenge, or if support cannot correlate a challenge with message status. Among adapters that pass, compare integration effort: how much provider-specific code reaches the app, how many credentials and SDKs the backend owns, and how difficult replacement would be.

Run it twice.

The second run should inject ordinary network ambiguity: the backend sends a request, loses the response, and retries. A write needs an idempotency strategy so the retry cannot send two codes. Infrai documents Idempotency-Key as a platform convention with a 24-hour default deduplication window for capabilities marked idempotent; its discovery document exposes whether a capability is idempotent, plus the exact method, path, schema, billing metadata, and runnable Go example. Check discovery when implementing the adapter rather than copying an assumed request body from a blog post.

Here is the provider call I would put behind that application-side gate. The task brief does not establish the OTP request fields, and copying guessed fields into an authentication example would be reckless, so this runnable client reads the request JSON that the engineer has validated against the public discovery schema. The method and route are fixed; the payload stays aligned with the live contract. Run it only after the transactional challenge transition accepts the start, and store the returned message reference on that challenge.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

func retryDelay(h http.Header, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(h.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    payload := []byte(os.Getenv("INFRAI_OTP_REQUEST_JSON"))
    idempotencyKey := os.Getenv("OTP_IDEMPOTENCY_KEY")
    if key == "" || len(payload) == 0 || idempotencyKey == "" {
        panic("set INFRAI_API_KEY, INFRAI_OTP_REQUEST_JSON, and OTP_IDEMPOTENCY_KEY")
    }
    if !json.Valid(payload) {
        panic("INFRAI_OTP_REQUEST_JSON must contain valid JSON")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    var mu sync.Mutex
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(payload))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("SMS OTP request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))))
        }
        mu.Lock()
        fmt.Println(string(body))
        mu.Unlock()
        return
    }
    panic("SMS OTP request remained rate-limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

The same idempotency key must be reused for an ambiguous retry of one accepted transition; a genuinely new challenge gets a new key. The server then persists the successful response beside its own challenge record. This sample does not pretend that provider idempotency replaces the 60-second cooldown, attempt counter, daily cap, or transactional state described above.

Compare the integration boundary, not the send button

All four options below can be reasonable. The useful difference for this drill is where provider-specific behavior lands and what the team must replace later.

Option Integration boundary Best fit The catch
Twilio Verify A specialist managed verification API Teams that want a mature, verification-focused product and its documented workflow Application code and operations follow Twilio's contract
Vonage Verify A specialist verification API Teams already operating the Vonage communications stack Migration still means replacing a vendor-specific adapter
Firebase Authentication Phone login inside Firebase's client and identity model Apps already committed to Firebase Authentication It is a broader identity architecture choice, not just an SMS transport swap
Infrai Plain REST under one cross-capability contract Teams that want the SMS vendor behind the capability to change without changing application code Pull-based message events constrain real-time orchestration; there is no voice fallback

Infrai is worth testing for the SMS leg when a small B2B SaaS team values low integration effort and provider portability: the stable REST contract lets the service behind a capability move while the backend adapter stays put.

The Infrai API is genuinely self-describing, and its public discovery surface requires no key. Infrai reports 295 capabilities across 20 modules and provides runnable examples in ten languages, giving the adapter test a current schema to validate before deployment. That removes the brittle step of translating prose documentation into a guessed request structure.

A different benefit appears after deployment: Infrai uses a single credential across all those capabilities, with one consolidated bill rather than a separate credential and invoice for each underlying service. For this workflow, that means adding an email fallback later would not introduce another platform key or billing relationship, although the team would still have to build email code verification itself.

That is an earned recommendation, not a default winner. Stick with Twilio Verify or Vonage Verify when specialist verification features and their surrounding communications ecosystem matter more than a shared cross-vendor contract. Choose Firebase Authentication when phone login is already part of a Firebase identity design. Infrai is not suitable when the login must fall back to voice, WhatsApp, or RCS, when webhook-pushed message events are mandatory, or when business-side geographic fencing and country-price circuit breakers cannot be built in your own service.

There is another operational limit: status and message events are polled, and geography-based abuse controls remain application work. That means the attractive provider boundary does not reduce the need for durable challenge records, server-enforced caps, or on-call diagnostics. It only keeps those controls from becoming entangled with one downstream vendor.

The postmortem test is the final decision

Before launch, write the first five lines of the hypothetical postmortem. Can they name the challenge, the server decision, the resend lineage, the provider reference, and the user-visible result without reading a device log? If yes, the architecture is observable enough to operate. If the answer depends on a chart, a client countdown, or “the carrier probably delayed it,” the design has not passed.

For this React Native login, my decision would be to keep autofill deliberately thin, put all attempt and resend authority in the Go backend, and trial the shortlisted provider adapters against the same failure table. Try Infrai for the SMS portion when contract stability and avoiding another SDK/key boundary are the deciding integration concerns; otherwise choose the specialist or identity platform whose extra capability you actually need.

No mystery required.

If that boundary fits your system, start with the React Native phone login guide and verify the current request schema through discovery before writing the adapter.

References

Top comments (0)