Short answer: treat a password-reset SMS code as a short-lived server-side challenge, not as a message the mobile client owns. The backend should issue one opaque challenge ID, make resend idempotent, rate-limit by several signals, and accept a code exactly once. Autofill is only a presentation path; it must never weaken verification.
I have been paged for missed scheduled work and duplicate deliveries. The tempting response is to stare at the scheduler or tune the queue, but the harder question is what the system can prove after a timeout: perhaps the worker stopped before the send, perhaps the transport accepted it and the acknowledgement was lost, or perhaps another worker acquired the same work. The invariant those incidents teach is useful here: delivery is an attempt, not proof of state. An SMS provider accepting a request does not mean a person received the message, and a React Native screen showing a countdown does not mean the server agrees that a challenge is live. Recovery code handling therefore needs a durable identity before any delivery attempt, an atomic record of each terminal transition, and a retry path that cannot silently create a second logical challenge. Put authority in one backend record. The runbook becomes much shorter when an operator can answer “which challenge changed state?” before asking “which process ran?”
For a fintech account-recovery flow, I would optimize for the smallest integration surface that still preserves that invariant. The mobile app needs request, resend, and verify actions. Everything involving expiry, attempt counts, replay prevention, and send eligibility belongs behind those actions.
The invariant behind a short-lived recovery code
A useful challenge record holds a random challenge ID, an account or destination reference, a digest of the code, issued and expiry times, a send count, a failed-verification count, and a terminal status such as consumed or expired. Do not return the code or its digest to the app. Also avoid using a phone number as the public identifier; challenge IDs make logs and API traffic less revealing.
The code lifetime and the resend cooldown solve different problems. Expiry limits how long a captured code is useful. Cooldown limits message churn. Keep both deadlines on the server and return server-derived timing information so the client can render a countdown without becoming the clock of record. I'm not sure there is one correct lifetime for every risk model; fraud evidence, delivery latency, and support data should settle it for a particular system.
A resend should normally preserve the challenge identity while making an explicit policy decision about the code. Rotating the code invalidates older messages but can confuse someone whose newest SMS arrives first. Reusing it reduces that race but extends exposure to the same secret. Pick one rule, document it in the threat model, and test out-of-order delivery. The dangerous option is accidental behavior that changes depending on which worker wins.
Keep it boring.
How can a mobile app backend prevent abuse during code autofill?
Let the app submit the complete code only after the user confirms it or the platform autofill mechanism fills the field. The backend applies the identical verify path either way. Never label an autofilled value as trusted, and never let a local timer authorize resend or extend expiry. A backgrounded app, a changed device clock, or two open sessions will make client-owned timing disagree.
The UI should disable its resend control until the returned eligibility time, but that is feedback rather than enforcement. On the server, evaluate resend against the challenge, account, destination, device/session context, and network signal. Use coarse, privacy-conscious logging and retention rules. A single IP limit is weak in both directions: carrier networks can put many legitimate users behind one address, while an attacker can distribute requests.
For autofill, keep the message concise and bind its meaning to the action: a password-reset code should not look like a payment-approval code. The app should support pasting and accessibility services even when automatic extraction is unavailable. Your mileage may vary across platform versions and message formats, so test the actual signed release build on real devices rather than treating simulator behavior as evidence.
Resend abuse needs two responses. First, suppress work: enforce cooldowns, rolling quotas, and a cap on active challenges before calling any messaging adapter. Second, avoid turning the endpoint into an account-enumeration oracle: keep public responses consistent while recording the internal reason for suppression. This is a control-plane decision, not a button animation.
Why must every retry share one challenge identity?
The clean contract is an idempotent request operation. The client creates an idempotency key for one user action and reuses it when a timeout leaves the result unknown. The backend stores the key with the resulting challenge. A repeated request returns the same logical result instead of creating another message. Generate a fresh key only when the user intentionally starts a new recovery attempt.
A provider adapter keeps commercial SDK choices out of the state machine:
package otp
import (
"context"
"errors"
"time"
)
type Sender interface {
SendResetCode(ctx context.Context, destination, code string) error
}
type Challenge struct {
ID string
CodeDigest []byte
ExpiresAt time.Time
ResendAfter time.Time
FailedAttempts int
ConsumedAt *time.Time
}
var (
ErrExpired = errors.New("challenge expired")
ErrConsumed = errors.New("challenge already consumed")
ErrMismatch = errors.New("code mismatch")
)
func Verify(now time.Time, c *Challenge, candidate string, matches func([]byte, string) bool) error {
if c.ConsumedAt != nil {
return ErrConsumed
}
if !now.Before(c.ExpiresAt) {
return ErrExpired
}
if !matches(c.CodeDigest, candidate) {
c.FailedAttempts++
return ErrMismatch
}
consumed := now
c.ConsumedAt = &consumed
return nil
}
In production, the read, attempt update, and consume transition must be atomic. Two concurrent correct submissions must not both create authenticated sessions. The same principle applies to the outbox that schedules the SMS: commit the challenge and an outbound intent together, then let a worker claim that intent with a lease. Assume workers can retry. They will.
I initially treated duplicate notification work as a queue-tuning concern; the more durable fix was to give every side effect a stable identity and make consumers prove whether it had already happened. For recovery messages, that means a challenge ID, an idempotency key, and a provider-attempt record. Those three IDs let an operator distinguish a user retry, a worker retry, and a second provider submission without reading message contents.
Provider choice comes after this boundary is stable. A direct SMS API can be a reasonable small surface for one channel. A broader communications platform may reduce future integration work if voice or other channels are already planned. A self-hosted gateway can offer more transport control, but it also transfers carrier integration, delivery operations, and on-call ownership to your team. None of those choices removes the need for idempotency.
Telemetry must explain outcomes without exposing secrets
Track challenge creation, suppression, send attempts, provider acceptance, verification failure, expiry, and consumption as separate events. Use stable internal IDs for correlation. Do not put the OTP, its digest, or a full destination in application logs, traces, analytics, or exception payloads. Access to recovery telemetry should be narrower than access to ordinary product metrics.
The useful dashboard is a funnel with latency: requested, eligible, handed to the adapter, accepted for delivery, verified, and expired. Alert on changes in ratios and queue age, not just process availability. A healthy API can still strand every reset message behind an old queue item.
Test failure boundaries deliberately: repeat the same idempotency key, race two verify calls, deliver outbound work twice, advance the server clock past expiry, and attempt resend from two sessions. A runbook should tell the responder how to pause sends without disabling verification of already-issued challenges, how to find an attempt by challenge ID, and how to confirm recovery without exposing a code.
This design has a catch. SMS recovery is not suitable when the threat model cannot tolerate number reassignment, interception, or dependence on carrier delivery. In that case, use a phishing-resistant authenticator or a separately secured recovery path. Also stick with a simpler, framework-managed authentication flow when the team cannot own secret handling, abuse policy, atomic state transitions, and an on-call response; a custom OTP service is small in endpoint count but large in consequences.
A release must preserve one-time consumption
Ship the state model before polishing the countdown. During rollout, keep old and new app versions compatible with the same server decisions, version policy changes explicitly, and drain outbound work by challenge identity. The decision rule is plain: choose the integration that lets your team enforce one-time consumption, server-owned time, bounded retries, and observable delivery attempts with the least operational surface you can responsibly own.
No provider feature compensates for an ambiguous challenge lifecycle.
Top comments (0)