Auth & Security

OTP login that doesn't fall over: rate limits, resend windows, and provider failover

One-time-password login looks simple until real users start mistyping codes, hammering resend, and hitting it from three cities at once. Here's how we kept OTP sign-up fast and abuse-resistant across a multi-city launch.

By Rehan Idrisi · · Updated · 5 min read

Part of: Custom Ecommerce

OTP sign-up is one of those features that takes an afternoon to demo and weeks to harden. It works perfectly the first ten times you test it. Then someone taps "resend" eleven times in a row, or a bot starts requesting codes for random numbers to run up your SMS bill, and the feature that felt finished turns out to have been only the happy path.

We built OTP-based sign-up for a rental marketplace onboarding renters and property owners across three cities simultaneously, and got a verified sign-up down to around 10 seconds end to end. The launch needed authentication fast enough not to lose an impatient visitor, and resilient enough not to be the thing that goes down on day one.

Rate-limit the request, not just the verify

Most OTP bugs live on the request side. Any unrestricted "send code" endpoint doubles as a free SMS-bombing tool aimed at whatever number an attacker types in. It also costs you money, because you pay your SMS provider per message whether the recipient wanted it or not.

So we cap requests per phone number, with a short cooldown between sends and a daily ceiling, and cap per IP address independently. Neither a single abused number nor a single abusive client can burn through the SMS budget on its own. Verification attempts get their own separate limit: a handful of tries per code before it is invalidated outright, forcing a fresh request instead of leaving an endless guessing window open.

OTP feels like a UI problem until the first abuse spike. Budget the rate limits and provider fallback before launch, not after the SMS bill arrives.

Make the resend window honest

A visible countdown before "resend" becomes clickable does two jobs at once. It stops the double-tap that generates two valid codes for the same session, which gets confusing when the first one still arrives. And it buys real time for the SMS to land before the user decides it failed and starts mashing the button.

Our resend cooldown is set slightly longer than the provider's typical delivery time, never to an arbitrary round number. If messages usually land in 8 seconds, a 30-second cooldown reads as safe instead of punitive.

Plan for the SMS provider having a bad day

Every SMS gateway has outages and carrier-specific delivery problems. From the user's side, a code that never arrives looks exactly like your app being broken. Route through a single provider with no fallback and their incident becomes your incident.

  1. Send through a primary provider first. Track delivery and failure webhooks instead of assuming a send succeeded.
  2. If a failure signal arrives within a short window, or a timeout passes with no delivery receipt at all, retry the same code automatically through a secondary provider.
  3. Log provider-level success rates per carrier. One carrier prefix failing consistently is a signal to route around it.

Keep the code itself boring

Six digits, numeric only, expiring in a few minutes, single-use, invalidated the moment a new one is requested for the same number. None of that is exciting, and it shouldn't be. OTP security comes from short exposure windows and low guess budgets, never from a clever code format. Store the code as a salted hash server-side, never compared as plaintext and never logged, and a database leak stops handing out valid, unexpired codes.

What happens the instant a code is verified?

Verification is the handoff into session issuance, and that step needs care of its own. On successful verification we issue a short-lived access token plus a longer-lived refresh token, and invalidate the OTP record at the same moment, not after the token is issued, so a valid code can never be replayed even inside a narrow race window.

New-device and new-city sign-ins get logged into the account's own activity history. A renter verifying from a phone that has never been seen against that account before shows up there, which gives a genuine owner a way to notice if their number was somehow verified from somewhere they weren't.

Testing an abuse-resistant flow without abusing your own SMS budget

You can't load-test OTP delivery against a real SMS provider without either a large bill or a very understanding account manager. The rate-limiting and state-machine logic runs against a mocked provider instead, one that can be told to simulate delays, failures, or a specific carrier's known quirks on command. Real end-to-end sends are reserved for a small, controlled smoke-test batch before launch and after any change to the provider integration itself.

Numbers that don't fit the six-digit-SMS assumption

Not every phone number behaves like the common case a demo is built against. Landline-registered numbers, numbers on carriers with poor SMS-to-app delivery, and international numbers outside the primary launch markets each needed a fallback path of their own. Numbers with a history of failed SMS delivery get offered a voice-call OTP instead of a quiet retry down the same channel that already failed them.

An OTP endpoint with no rate limit is a paid SMS relay for anyone who finds it.

How do you rate-limit OTP login without blocking real users?

Apply independent limits on two axes: a cooldown plus daily cap per phone number, and a separate cap per IP address, so one abused number or one abusive client can't exhaust your SMS budget alone. Give verification attempts their own tight limit per code, and invalidate a code the moment a new one is requested.

Share this guideLinkedInXWhatsAppFacebook
All guides