Every product I've seen ship SMS one-time codes has the same bug on day one. It doesn't show up in
staging, it shows up in the bill and in support tickets: **users receive two codes, and the second
one invalidates the first.**
Here's the sequence that causes it.
user taps "Resend" ──▶ POST /otp/send ──▶ generate code A ──▶ provider call (slow, 900ms)
user taps again ──▶ POST /otp/send ──▶ generate code B ──▶ provider call
store code B (overwrites A)
SMS with code A arrives first. User types A. Server compares to B. "Invalid code."
Two charges, one angry user, and a support ticket that says "your OTP doesn't work." The naive fix
— disable the button for 30 seconds client-side — moves the bug rather than removing it. Mobile
clients retry on flaky networks without any human tapping anything.
The core mistake is treating send as the unit of work. The unit is the challenge. A challenge
has one code and a TTL; a resend delivers the same challenge again.
def start_challenge(user_id: str) -> Challenge:
existing = store.get_active(user_id) # not expired, not consumed
if existing and existing.age < timedelta(minutes=2):
return existing # resend delivers THIS code again
code = secrets.randbelow(1_000_000)
return store.put(Challenge(
user_id=user_id,
code_hash=hash_code(f"{code:06d}"), # never store the code itself
expires_at=now() + timedelta(minutes=5),
attempts=0,
))
Store the hash, not the code. Your OTP store is a credential store; treat it like one.
Even with one challenge, network retries can duplicate the delivery. Attach an idempotency key
derived from the challenge and the attempt number, and let the provider (or your own dedupe layer)
collapse repeats:
key = f"{challenge.id}:{challenge.delivery_count}"
sms.send(to=phone, text=f"Your code is {code}", idempotency_key=key)
Any provider worth using either honours an idempotency header or gives you a message ID you can
reconcile. If yours does neither, wrap it: a Redis SET key NX EX 300 before the call costs one
round trip and kills the duplicate-charge class of bugs outright.
Limit per phone number and per IP. Per-IP alone lets one attacker drain your balance across
many numbers (SMS pumping — the fraud where an attacker cycles premium-rate numbers to farm
carrier revenue share). Per-phone alone lets a botnet hammer from everywhere.
allow = limiter.check(f"otp:phone:{phone}", limit=5, window=3600) and \
limiter.check(f"otp:ip:{ip}", limit=20, window=3600)
def verify(user_id: str, submitted: str) -> bool:
ch = store.get_active(user_id)
if not ch or ch.attempts >= 5 or ch.expires_at < now():
return False
ch.attempts += 1 # increment BEFORE comparing
store.save(ch)
if not hmac.compare_digest(ch.code_hash, hash_code(submitted)):
return False
store.consume(ch) # single-use, always
return True
Three details people skip: increment attempts before the comparison (or a crash mid-verify gives
free retries), use a constant-time compare, and consume the challenge on success so a replayed code
can't authenticate twice.
None of this matters if the message is filtered. In a large share of countries an alphanumeric
sender ID ("MyBrand") must be pre-registered before messages are delivered, and in some it's
blocked outright — your perfectly correct pipeline silently drops traffic in exactly one country
and you find out from a churn report.
We maintain an open dataset of those rules — sender-ID regime, registration requirement, regulatory
basis and last-verified date per country — as CSV/JSON:
github.com/SMSRoute-cc/sms-sender-id-regulations (CC-BY-4.0)
There's a second trap in the same table: the charset_class column. If the local language forces
UCS-2 encoding, a segment holds 70 characters instead of 160 — so a message you sized for one
segment silently bills as two or three. Worth checking before you write your template copy, not
after.
The code samples above are provider-agnostic; any HTTP SMS API fits behind that interface. I work on
smsroute.cc, which is where the dataset comes from — new accounts get free
credits if you want to test a delivery path, and the client libraries are
Python /
Node, both MIT.