OTP resend race conditions cause duplicate SMS charges. Stop them with one challenge, an SMS idempotency key, and server-side dedupe—not a disabled button.
Every SMS OTP pipeline I’ve audited burns money the same way on day one. It rarely shows in staging. It shows on the carrier bill and in support: two message segments charged, two codes delivered, the second invalidating the first, user locked out on the code that arrived first. Duplicate MT cost is pure margin loss; the support load is the multiplier.
Client-side “disable Resend for 30 seconds” is UX. Mobile runtimes and flaky networks retry POST /otp/send with no human tap. If the server treats every request as “mint a new code and bill a new MT,” you lose. Ship button disable and a server idempotency contract together, or juniors will ship the button and call it done.
The OTP resend race condition (two charges, one failure)
user taps Resend ──▶ POST /otp/send ──▶ code A ──▶ provider call (~900ms)
user/network retry ──▶ POST /otp/send ──▶ code B ──▶ provider call
store B (overwrites A)
SMS A arrives first → user enters A → server has B → "Invalid code."
You paid for A and B. Only B is valid. Support ticket writes itself.
The unit of work is not “send.” The unit is the challenge: one secret, one TTL, one attempt budget. Resend re-delivers that challenge. It does not mint a sibling.
One challenge, many SMS deliveries
If start_challenge only returns an existing row and you never show where plaintext comes from, the “resend same code” checklist is theater. You need a full send path: create-or-reuse, decrypt-or-generate in memory, then provider call.
Store the secret encrypted (or peppered-HMAC) at rest. Plaintext exists only in send-path memory—never in logs, never in a support tool dump.
import os, hmac, hashlib, secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
# secrets only. Never math.random / random.random / PRNGs for OTPs.
def generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
def encrypt_code(code: str) -> bytes: ...
def decrypt_code(blob: bytes) -> str: ...
@dataclass
class Challenge:
id: str
user_id: str
code_encrypted: bytes
expires_at: datetime
attempts: int
delivery_count: int
consumed: bool
def get_or_create_challenge(user_id: str) -> tuple[Challenge, str]:
"""Return (challenge, plaintext). Plaintext stays in send memory only."""
existing = store.get_active(user_id) # not expired, not consumed
if existing and existing.age < timedelta(minutes=2):
return existing, decrypt_code(existing.code_encrypted)
code = generate_code()
ch = store.put(Challenge(
id=new_id(),
user_id=user_id,
code_encrypted=encrypt_code(code),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
attempts=0,
delivery_count=0,
consumed=False,
))
return ch, code
Reuse window (here two minutes) is product policy: long enough to absorb double-taps and HTTP retries, short enough that a stalled user eventually gets a fresh secret after expiry.
Full send path—rate limits, idempotency slot, provider call, persist message_id before ACK:
def send_otp(user_id: str, phone: str, ip: str) -> dict:
if not (limiter.check(f"otp:phone:{phone}", 5, 3600) and
limiter.check(f"otp:ip:{ip}", 20, 3600)):
raise RateLimitError("otp send rate limited")
challenge, code = get_or_create_challenge(user_id)
# Intentional resend bumps delivery_count only after a finalized prior send.
# Network retries of *this* slot reuse the same key (see table below).
slot = challenge.delivery_count
idem_key = f"{challenge.id}:{slot}"
# Local dedupe even when the upstream API is weak or ambiguous.
if not redis.set(f"sms:idem:{idem_key}", "pending", nx=True, ex=300):
return {"status": "already_accepted", "challenge_id": challenge.id}
try:
result = sms.send(
to=phone,
text=f"Your code is {code}",
idempotency_key=idem_key, # Twilio-style when the API supports it
)
except ProviderTransientError:
# 5xx / timeout: allow retry with the SAME key; do not mint a new slot.
redis.delete(f"sms:idem:{idem_key}")
raise
except ProviderRateLimitError:
# 429: do not spin a new idempotency key to "get through."
raise
# 2xx with provider message_id → persist BEFORE acking the client.
store.save_delivery(challenge.id, slot, result.message_id)
store.increment_delivery_count(challenge.id)
redis.set(f"sms:idem:{idem_key}", result.message_id, ex=86400)
return {
"status": "sent",
"challenge_id": challenge.id,
"message_id": result.message_id,
}
Your API should also accept an optional client Idempotency-Key on POST /otp/send. The Resend button generates a new key (new delivery slot). HTTP retries replay the same key. Button disable alone never replaces that contract.
Provider idempotency keys (and Redis NX)
“Any HTTP SMS API is fine” is false for billing safety. Twilio-style Idempotency-Key (or equivalent) collapses duplicate POSTs into one MT. Generic “send” endpoints without that semantic will double-charge on retry unless you wrap them.
Practical rule:
- Prefer a provider that honors a Twilio-style
Idempotency-Key(or documents equivalent dedupe). - Otherwise your Redis
SET key NX EX …wrapper is the idempotency layer—put it in front of every billable send. - Keys must be stable for a retry of the same logical delivery (
challenge_id + delivery_slotor the client idempotency key), and distinct for an intentional resend.
When to retry, when you are charged
| Outcome | What to do | Charge / key rule |
|---|---|---|
| 429 rate limit | Back off; surface retry-after. | Do not retry with a new idempotency key to bypass the limit. |
| 5xx or timeout | Retry the send. | Reuse the same idempotency key; clear only your local NX “pending” marker if you need the retry to proceed. |
| 4xx (bad number, invalid payload) | Fix input or stop. | Do not retry blindly; no new key will help a permanent client error. |
2xx + provider message_id
|
Persist message_id (and slot) before ACKing the client. |
That row is your proof against a second billable push for the same slot. |
Charge timing differs by vendor (accept vs. deliver). You cannot control carrier handoff, but you can ensure two app retries do not become two accepted sends. Persist provider ids first; then ACK. If the process dies after provider success but before DB write, reconciliation jobs use the idempotency key / message id—not a blind resend with a fresh key.
Rate limit phone + IP against SMS pumping
Two axes, always:
- Per phone — stops one number from draining balance (and stops user self-DoS).
- Per IP — slows distributed abuse from one botnet node.
Per-IP alone fails when attackers cycle numbers. Per-phone alone fails when they spray from many IPs. Together you raise the cost of SMS pumping (fraud that drives traffic toward premium-rate or revenue-share numbers so the attacker skims carrier share while you pay MT).
allow = (
limiter.check(f"otp:phone:{phone}", limit=5, window=3600) and
limiter.check(f"otp:ip:{ip}", limit=20, window=3600)
)
Tune with real traffic. Add velocity alerts on sends per country and on sudden spikes in failed verifies after successful sends—that pattern is often pumping or bot signups, not UX confusion.
Verify: atomic attempts, constant-time, single-use
The naive “read → compare → save attempts” path loses races: two parallel verifies both read attempts == 4, both succeed the check, both authenticate. Increment must be conditional and atomic, and success must consume in a compare-and-swap style op.
def verify(user_id: str, submitted: str) -> bool:
# Atomic claim of one attempt, or fail closed.
# SQL sketch:
# UPDATE challenges
# SET attempts = attempts + 1
# WHERE user_id = :uid
# AND consumed = false
# AND expires_at > :now
# AND attempts < 5
# RETURNING *
ch = store.claim_verify_attempt(user_id, max_attempts=5, now=datetime.now(timezone.utc))
if ch is None:
return False
plaintext = decrypt_code(ch.code_encrypted)
match = hmac.compare_digest(plaintext, submitted.strip())
if not match:
return False
# Single-use: only one winner if two verifies race after a match.
if not store.consume_if_active(ch.id):
return False
return True
Details that matter in production:
- Attempt budget burns even on mismatch—and the burn happens in the atomic claim, not after a slow compare.
-
hmac.compare_digest(constant-time) for the secret compare—no early-return string equality. - Consume on success so a replayed code cannot authenticate twice.
- Fail closed when the claim or consume loses a race; do not “helpfully” retry the compare in process.
Optional hardening: store a peppered HMAC and the ciphertext, verify against the HMAC so the verify path never needs decrypt. Resend still decrypts once in send memory only.
When the code never arrives (sender ID + encoding)
Idempotency does not help if the MT is filtered. In many countries alphanumeric sender IDs (MyBrand) must be pre-registered, and in some they are blocked outright. Your pipeline returns 200, the provider accepted the message, and the handset gets nothing—you learn from churn, not from staging.
We publish an open country dataset (CC-BY-4.0) with columns including sender_id_regime, registration_required, charset_class, regulatory basis, and last-verified date:
github.com/SMSRoute-cc/sms-sender-id-regulations
Same table, second foot-gun: charset_class. GSM-7 holds 160 characters single-segment and 153 per segment when concatenated. UCS-2 (common once you leave basic Latin) holds 70 single-segment and 67 concatenated. A template sized for one GSM-7 segment becomes two or three UCS-2 segments without a single logic bug in your OTP state machine—and you pay per segment.
Check registration regime and charset before you freeze copy and sender ID, not after a country-specific outage.
Production checklist
- [ ] One active challenge per user; resend re-delivers, does not regenerate inside the reuse window
- [ ] Full send path: get-or-create → plaintext only in memory → provider call
- [ ] Code encrypted or peppered-HMAC at rest; plaintext never logged
- [ ] OTP from
secrets/ CSPRNG only—rejectmath.randomand insecure RNGs - [ ] Idempotency key per delivery slot (Twilio-style
Idempotency-Keyor your Redis NX wrapper) - [ ] Client Resend button disabled and server idempotency contract enforced
- [ ] Retry map: 429 no new key; 5xx/timeout same key; 2xx persist
message_idbefore client ACK - [ ] Rate limit per phone and per IP; watch for SMS pumping velocity
- [ ] Verify: atomic attempt claim, constant-time compare, single-use consume
- [ ] Sender-ID rules checked per country you ship to (
sender_id_regime,registration_required) - [ ] Templates budgeted for GSM-7 160/153 vs UCS-2 70/67 (
charset_class)
Wire this once at the challenge layer and duplicate SMS charge classes drop out of the bill. For a concrete HTTP SMS API with crypto payment and free test credits, smsroute.cc is what we use behind the same idempotency wrapper—pair it with the sender-ID dataset above before you open a new country.
Top comments (0)