Store OS, Part 2: SMS-OTP Auth for the Iranian Market
Why TITAN treats checkout and login as the same action, how its OTP rate limits are structured, and the Kavenegar detail that keeps codes arriving at 11pm — session 4 of building our own store.
The decision that shaped everything else
Session 4 of building TITAN — our own 3D-print storefront, not a client engagement — made one call that everything downstream depends on: verifying a phone number at checkout is the same act as logging in. There's no separate signup form. A guest who verifies their phone to place an order simultaneously creates their account. Most Iranian shoppers will sit through exactly one phone verification, and it has to be the one that's already getting them their order — not a second, optional step nobody completes.
Three rate limits, cheapest first
OTP requests pass through three independent KV rate limits, deliberately ordered
cheapest-check-first: a per-IP hourly cap, then a per-phone daily cap, then a tight
per-phone 10-minute window. An abusive burst gets rejected by the first, cheapest check
before the system ever pays for the more expensive ones. We tested this live, not just
read the code: four rapid OTP requests for the same phone number returned 200 three
times and 429 on the fourth, exactly as designed.
An expiry bug we designed around before it happened
The 5-digit OTP code is stored with an absolute expiry timestamp, kept deliberately separate from the storage layer's own TTL. The reason: every wrong guess increments an attempt counter, which re-saves the KV entry — and a naive relative TTL would silently slide forward on every save, extending a code's lifetime every time someone guessed wrong. Splitting "when does this expire" from "when does the storage entry expire" closes that hole by construction instead of relying on nobody noticing.
Lockout is hard: five wrong attempts and the phone is locked, even if attempt six happens
to be the correct code. We verified that live too — five wrong guesses all returned
invalid_code, and a sixth attempt with the actually-correct code still returned
too_many_attempts.
Why Kavenegar's template send, specifically
SMS delivery goes through Kavenegar's VerifyLookup pattern-send — a template-based transactional API, distinct from the free-text endpoint used for marketing campaigns later in the build. The distinction matters because Iran enforces a nightly blackout on promotional SMS; transactional pattern-sends are exempt. A customer checking out at 11pm shouldn't have to wait until morning for a login code, and building the auth flow on the wrong SMS endpoint would have made that true by accident.
What's next
Part 1 of this series covers the commerce core and the ten-agent cron engine this auth system feeds into — see Store OS, Part 1. The full build story is at /work/titan. If your product needs auth that actually matches how your market verifies identity — not a generic email/password form — see how we approach e-commerce builds.
Want the checklist for your own build?
Tell us what you're working on — we respond with an architecture sketch within 48 hours.
Read next: Store OS, Part 1: Commerce Agents on Workers and D1