SMSRoute Dev

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.

The race

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."

Enter fullscreen mode Exit fullscreen mode

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.

Fix 1: one challenge, many deliveries

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,
    ))

Enter fullscreen mode Exit fullscreen mode

Store the hash, not the code. Your OTP store is a credential store; treat it like one.

Fix 2: an idempotency key on the send

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)

Enter fullscreen mode Exit fullscreen mode

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.

Fix 3: rate limit on two axes, not one

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)

Enter fullscreen mode Exit fullscreen mode

Fix 4: a verify path that actually fails closed

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

Enter fullscreen mode Exit fullscreen mode

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.

The part nobody tells you: the code may never arrive

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.

Checklist

  • [ ] One challenge per user; resend re-delivers, never regenerates
  • [ ] Store a hash of the code, never the code
  • [ ] Idempotency key on every send
  • [ ] Rate limit per phone and per IP
  • [ ] Attempt counter incremented before comparison; constant-time compare
  • [ ] Single-use consumption on success
  • [ ] Sender-ID rules checked for every country you actually ship to
  • [ ] Encoding checked — accented/non-Latin copy costs 2–3× per message

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.