Short answer: model each SMS OTP as an auditable challenge that can be consumed once, and make the server—not the mobile screen—the authority for expiry, autofill acceptance, repeat-request limits, and recipient suppression. Those decisions belong in the security contract before a messaging adapter is selected.
The concrete problem is deceptively small: a mobile user asks for a code, the app receives a text, and the user signs in. In production, the same endpoint is also a spending endpoint, a privacy boundary, and a fraud signal. A duplicate tap, a delayed carrier message, or a recycled phone number can turn a pleasant login flow into an account-enumeration or SMS-bombing incident.
I approach this like a ledger. Every state transition needs an idempotency key, an audit record, and a clear owner. Seven invariants keep the design reviewable.
Stop.
Consent, retention, and privacy records
The server creates a challenge with a random, short-lived code, stores only a salted hash, and binds the challenge to a normalized recipient plus a login intent. The client receives an opaque challenge identifier; it never decides whether a code is valid. Verification consumes the challenge atomically, so two concurrent requests cannot both win.
A resend is a new delivery attempt on the same login intent, subject to a cooldown and a rolling budget. It must not silently invalidate a code that is already in transit unless the product explicitly documents that behavior. Suppression is checked before dispatch and again when delivery feedback is ingested. That second check matters for bounces, reassigned numbers, and manually blocked recipients.
| Option | Strength | Cost or boundary |
|---|---|---|
| One service owns challenge and delivery state | Simple audit trail and exactly-once verification | Requires a durable store and transactional writes |
| Separate identity and messaging services | Teams can deploy independently | Correlation IDs and replay rules cross a network boundary |
| Client-generated code or expiry | Fast prototype | Cannot provide trustworthy auditability or abuse controls |
| Synchronous send in the login request | Straightforward UI state | Carrier latency makes retries and duplicate sends likely |
The rejected option is client-generated OTP state. It looks attractive in a demo, then fails the first time a rooted device edits its clock or replays a request. It is still suitable for a non-authentication, offline puzzle where the value has no security meaning.
What should a mobile SMS OTP login backend API prove before autofill?
Autofill is a presentation hint, not proof of possession. On iOS and Android, the SMS body can use the platform's one-time-code conventions; the backend still receives the code through the same verify operation, with the challenge ID and a request nonce. The app should submit an autofilled value exactly as typed, display a generic failure, and avoid revealing whether a phone number has an account.
The request contract can remain boring. Boring is good. Here is a Go sketch with generic paths; the storage and SMS adapter are deliberately interfaces so a provider change does not alter the security model.
I've learned to write the duplicate-tap case down before discussing UI polish. Imagine two requests with the same idempotency key arriving 40 milliseconds apart while the first worker is waiting on a carrier adapter. The first transaction reserves the challenge and intent, the second reads the reservation and returns the same opaque identifier, and neither path is allowed to mint a second code. If the adapter later reports a transient timeout, the audit stream still contains one attempted handoff and the queue can retry under the same intent budget. That sequence is more useful than a screenshot because it tells the reconciliation job what “one send” means, gives support a correlation ID, and prevents a retry storm from becoming a billable flood. It also makes a compliance review finite: the reviewer can follow issued, attempted, suppressed, and verified events without trusting client timestamps.
type ChallengeStore interface {
Create(ctx context.Context, c Challenge) error
Consume(ctx context.Context, id, codeHash string) (bool, error)
CountResends(ctx context.Context, intentID string, since time.Time) (int, error)
}
type Delivery interface {
Send(ctx context.Context, recipient, body string) (string, error)
}
func RequestOTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
recipient, intentID, idem := parseRequest(r)
normalized := normalizePhone(recipient)
if suppressed(ctx, normalized) || overBudget(ctx, normalized) {
writeGenericAccepted(w)
return
}
if prior := idempotency.Lookup(ctx, idem); prior != nil {
writeJSON(w, prior)
return
}
code := randomDigits(6)
challenge := Challenge{
ID: newID(), IntentID: intentID, Recipient: normalized,
CodeHash: hash(code), ExpiresAt: time.Now().Add(5 * time.Minute),
}
if err := store.Create(ctx, challenge); err != nil {
writeGenericAccepted(w)
return
}
deliveryID, err := sms.Send(ctx, normalized, formatBody(code))
audit.Record(ctx, "otp.requested", challenge.ID, deliveryID)
saveIdempotency(ctx, idem, challenge.ID)
if err != nil {
// Keep the public response generic; retain the internal audit event.
writeGenericAccepted(w)
return
}
writeJSON(w, map[string]string{"challenge_id": challenge.ID})
}
func VerifyOTP(w http.ResponseWriter, r *http.Request) {
id, code := parseVerification(r)
ok, err := store.Consume(r.Context(), id, hash(code))
if err != nil || !ok {
writeStatus(w, http.StatusUnauthorized)
return
}
audit.Record(r.Context(), "otp.verified", id, "")
issueSession(w, id)
}
The example intentionally records a delivery identifier even when the provider reports a failure. That is not a claim that delivery succeeded; it is an audit fact about the attempted handoff. A separate feedback consumer marks hard bounces or invalid recipients as suppressed, while transient failures remain retryable under a bounded queue policy.
Resend abuse is usually cheaper to stop than to investigate. Enforce limits at several keys: account or intent, normalized recipient, device fingerprint, IP range, and a global budget. Use a token bucket or a leaky bucket with a clock from the server. Return the same public shape for accepted, suppressed, and throttled requests, and attach a correlation ID for support.
Five minutes is an example policy, not a universal truth. Carrier behavior, threat model, and regulatory requirements should set the expiry and maximum attempts; document the decision in the threat model and test it against the relevant telecom and privacy rules. Your mileage may vary.
Failure events and retry reconciliation
A login challenge has distinct states: issued, delivery-attempted, verified, expired, locked, and suppressed. Do not collapse delivery-attempted into delivered. SMS systems expose status changes asynchronously, and a handset can be unreachable even when the initial request was accepted. The mobile UI can show “code sent” without promising arrival.
For each event, persist the challenge ID, intent ID, recipient hash, provider message ID when available, event time, and reason code. Keep the raw phone number out of routine logs; access-controlled evidence can retain what compliance requires. Google’s sender guidance is email-focused, but its authentication, consent, and unsubscribe principles illustrate the broader rule: delivery evidence and user consent are separate records.
An invalid recipient should end future sends for that normalized address until a deliberate re-verification path clears the suppression. A user who mistypes a number needs a correction flow, not an automatic loop of retries. The catch is that aggressive suppression can block a legitimate user after a carrier-side classification error; route appeals to a controlled support process rather than adding a hidden bypass.
Rollout gates and rollback evidence
Start with deterministic tests for the invariants: two verifies on one challenge produce one session; a resend cannot exceed its budget; an expired code never succeeds; a suppressed recipient causes no delivery call; and an idempotent retry returns the original challenge. Add property tests that generate duplicate and reordered events.
Then run a small integration matrix on physical iOS and Android devices. Check paste and autofill, clock skew, app restarts, airplane mode, and a message arriving after a resend. I'm not sure any simulator reproduces carrier timing faithfully, so treat simulator success as a UI check, not delivery evidence.
Metrics should answer operational questions without becoming a data leak: challenge issuance rate, verification success by age bucket, resend rate, suppression additions, and provider latency. Alert on a sharp change in recipient concentration or verification failures, not on a single user’s phone number.
When centralized ownership stops fitting
A single challenge service is the defensible default for a small team because it keeps idempotency, suppression, and audit trails in one transaction boundary. A split design becomes reasonable when identity and communications have separate availability objectives, provided every event carries a stable intent ID and reconciliation is treated as a first-class job.
This design is not suitable when SMS is the only high-assurance factor for high-value actions, in regions where SMS delivery is unreliable, or when local law requires a channel you cannot operate. Stick with a stronger authenticator, email plus device binding, or a regulated local gateway when those constraints dominate. Integration effort is only one axis; correctness and evidence decide whether the login can be defended later.
Top comments (0)