SMSRoute engineering notes

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

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

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)

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)

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

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

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.