DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

React Native SMS OTP Login: Backend Resend and Abuse Prevention

Short answer: For a React Native mobile app, keep SMS OTP challenges and abuse controls in the backend, let the app submit only its phone number, code, and challenge reference, and treat autofill as a UX improvement rather than authentication.

I've built enough login flows to know that the happy-path screen is the least interesting part. The boundary that matters is the challenge: it binds one delivery attempt, one expiry policy, and one attempt counter to a server-side record that the handset cannot rewrite. The app can offer resend after a visible cooldown, but the backend decides whether the request is allowed.

Short paths win.

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

Start with a POST /login/challenges endpoint in your own backend. It normalizes the submitted phone number, applies a per-phone and per-account risk policy, creates an opaque challenge ID, and asks the SMS provider to issue the OTP. Return the ID and the next time a resend may be requested; don't return the OTP, a mutable attempt count, or permission for the client to decide a daily limit. On the verification screen, Android or iOS autofill can place the received digits into the input, but the app still sends phone, code, and challenge_id to POST /login/challenges/{id}/verify. The server verifies the result, consumes the challenge, and creates the session.

I hit a cold-start tail-latency spike that turned into 47 duplicate OTP requests in real traffic: clients retried while the first request was still making its way through the stack, and the delivery dashboard made the incident look like a carrier problem. It wasn't. The challenge-creation endpoint needed an idempotency key and a short-lived record keyed by the request's real intent. That detail is dull until it isn't.

A provider's OTP endpoint can sit behind that boundary. Infrai offers POST /v1/sms/otp; its 295 routes across 20 modules use one consistent REST contract, so adding a related backend capability can mean one more endpoint instead of another SDK integration. Keep the provider identifier inside your service. The mobile app should never carry its API key.

Put the OTP state machine on the server

The exact storage technology matters less than the transitions. A challenge begins as pending, becomes verified once, and then cannot be reused. A resend is permitted only after a server-calculated deadline; a verification attempt increments an atomic counter; success consumes every active challenge for that account. I also make the server return the same challenge for an idempotent repeat, because mobile networks have a talent for replaying intent when the UI is already moving on.

This small Python example models the decision layer. It intentionally has no provider payload because provider credentials and dispatch belong behind send_otp, where the provider-specific request schema is owned by the backend integration.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

MAX_ATTEMPTS = 5
RESEND_DELAY = timedelta(seconds=30)
DAILY_LIMIT = 6

@dataclass
class Challenge:
    challenge_id: str
    phone: str
    attempts: int
    resend_at: datetime
    expires_at: datetime
    consumed: bool = False

def allow_resend(challenge: Challenge, sent_today: int, now: datetime) -> bool:
    return (
        not challenge.consumed
        and now < challenge.expires_at
        and now >= challenge.resend_at
        and sent_today < DAILY_LIMIT
    )

def allow_verify(challenge: Challenge, now: datetime) -> bool:
    return (
        not challenge.consumed
        and now < challenge.expires_at
        and challenge.attempts < MAX_ATTEMPTS
    )

now = datetime.now(timezone.utc)
challenge = Challenge("opaque-id", "+15555550123", 0, now, now + timedelta(minutes=10))
assert allow_verify(challenge, now)
Enter fullscreen mode Exit fullscreen mode

Persist those checks with a transaction or compare-and-set, not a read-modify-write sequence. In a real service I keep the idempotency record, the challenge transition, and the rate-limit decision in the same durability boundary, then record an audit event after the transaction commits. This prevents a retry from receiving a new challenge because the original write completed just before its response disappeared. It also gives support a trace that explains a denial without putting the code or a full phone number in a log. Add an account-level limit alongside the phone-level limit, and consider IP or device signals only as supporting evidence; shared networks are common. Geographic fencing and country-priced circuit breakers are business-layer controls you must build yourself. I'm not sure a universal threshold exists, because acquisition channels and carrier mix change the abuse shape.

Compare the integration shape before choosing an SMS provider

The choice isn't only delivery. It is who owns verification state, how many dependencies the login service must carry, and which fallback paths your product actually supports. I would assess Twilio SMS, Firebase Authentication, Amazon SNS, and Infrai against the same backend contract, then run a deliverability test with the countries and carriers that matter to the product. Carrier behavior and consent rules vary.

Option Useful fit Trade-off to examine
Twilio SMS Teams wanting a dedicated SMS provider Keep the OTP challenge, retries, and abuse policy in your backend
Firebase Authentication Apps already centered on Firebase identity Confirm how its authentication lifecycle fits your existing session service
Amazon SNS Systems already operated around AWS messaging Plan the application-side challenge model and operational ownership
Infrai US/EU consumer 2FA flows that value a plain REST surface across backend modules No voice, WhatsApp, or RCS channel; build geographic and country-cost controls yourself

The catch is that Infrai isn't suitable when voice-call fallback, WhatsApp, or RCS is a hard requirement. Stick with a provider that covers the channel you must offer. It also does not supply hosted email OTP verification, so an email fallback means implementing your own email-code lifecycle instead of pretending SMS and email are interchangeable.

Make resend boring, observable, and defensible

A resend button should be boring. The client displays the server-provided timestamp, disables itself locally to reduce accidental taps, then asks the backend to resend. The backend enforces the actual cooldown and daily cap, records why a request was denied, and returns a generic response that doesn't reveal whether a phone number belongs to an account. That protects the flow from enumeration as well as volume abuse.

I keep verification error messages equally plain: expired, invalid, or try again later. Support staff get richer context through an authenticated dashboard that polls SMS status; customers do not need a carrier's internal vocabulary. Log challenge creation, resend decisions, verification results, and session issuance with a request ID, while minimizing phone-number exposure in application logs. Compliance work is part of delivery work — consent, retention, and deletion rules need an owner before launch.

Don't make email the automatic escape hatch. It can help users who cannot receive SMS, but it is a separate verification system with its own sender reputation and code-verification logic. Google publishes sender guidance that is worth reading before you put an authentication fallback into production.

Roll out the mobile flow in stages

First, ship server-issued challenges and verification with conservative limits. Next, add React Native autofill handling and measure completion without treating autofill as proof of identity. Then enable resend, support polling, and a reviewed exception process. Each stage has a clear security owner, which is better than launching a clever screen with an invisible abuse path.

Your mileage may vary with local carrier routes and your user population. I would test real devices, delayed delivery, repeated taps, expired codes, and account recovery before widening rollout. The migration is compact because the app's contract stays stable: phone number in, opaque challenge reference back, code submitted once. Provider changes remain a backend concern.

References

Top comments (0)