SMS OTP best practices: expiry, rate limits, and auto-read
7 June 2026 · The SMSAPI team
A one-time passcode is a small thing that fails in expensive ways. Too loose, and it's a security hole; too strict, and real users get locked out and abandon signup. Here are the practices that make an SMS OTP flow both secure and low-friction in India — most of which you get for free if you let the provider handle the code, and a few you own regardless.
Don't store the code — verify it
The single most important rule: your application should never see, store, or log the OTP. Generate the code on the provider's side, deliver it to the user, and verify the user's input against a verification ID. If the code never touches your database or your logs, it can't leak from them.
This also removes a pile of code you'd otherwise write and secure: generation, hashing, expiry, attempt counting. You call send, you get a verification ID back, and you call verify with what the user typed. That's the whole flow. The OTP API overview shows the two calls end to end.
Keep the time-to-live short
A code should be valid for minutes, not hours. A short time-to-live (TTL) limits the window in which an intercepted or shoulder-surfed code is useful. Five to ten minutes is typical; long enough for a real user to switch apps and type it, short enough that a stale code is worthless.
Make the expiry visible to the user — "valid for 10 minutes" — so the deadline is part of the experience rather than a surprise.
Cap attempts and rate-limit requests
Two limits matter. First, cap the number of verify attempts per code: after a handful of wrong guesses, lock the code. Combined with a short TTL and a six-digit space, that makes brute force impractical — a guessed code expires long before it can be found.
Second, rate-limit send requests per number and per account: how many codes a single number can request in a window, and how fast. This stops someone using your OTP endpoint to pump SMS at a target's phone (an "SMS bomb") or to run up your bill. Read the rate-limit headers the API returns and back off cleanly rather than retrying blindly.
Handle the SMS that doesn't arrive
Delivery is never 100%. The flow has to handle the code that doesn't show up, or you lose the user at the last step.
Two tools help. Resend — let the user request the code again, but reuse the same verification rather than starting a new one, so your attempt counter stays meaningful and the first code isn't orphaned. And voice fallback — if the SMS isn't delivered within a window, the same code is read out on a voice call, with the verification ID unchanged so your verify call doesn't branch. Voice fallback turns a failed verification into a completed one, and it costs nothing until it's used.
Use auto-read on Android
The biggest source of drop-off in a verification isn't security — it's the user switching to their SMS app, reading the code, and typing it back. On Android, you can remove that step entirely with the SMS Retriever API: include your app's hash in the request, format the message correctly, and the code fills itself in without the user reading or typing anything, and without the app holding the SMS permission.
It's a convenience layer on the same flow — the verification ID and verify call don't change — so there's little reason not to use it. The auto-read guide covers the setup, and the iOS and web equivalents (keyboard AutoFill and the WebOTP API) that get most of the way there on other platforms.
Get the message right
A few content details lift completion rates:
- Put the code early. Lead with the digits so a user scanning a notification sees them without opening the message.
- Say what it's for and how long it's valid. "Your SMSAPI login code is 481920. Valid for 10 minutes."
- Don't include a link. Codes and links together train users to tap, which phishers exploit. Keep OTP messages link-free.
- Match the registered template. Under DLT, OTPs are service-implicit traffic, exempt from DND so they reach users at any hour — but only if the template is registered in the right category. Content that drifts from the approved template gets scrubbed.
Think about the second factor
If you're using OTP for two-factor authentication, target the friction at the risk rather than applying it everywhere. Require a code at login from a new device, and as step-up before a sensitive action — a payment, a password change, adding a payee — rather than on every action. That keeps trusted sessions smooth and reserves the friction for the moments that matter. The 2FA guide goes deeper on where a second factor belongs.
Test the whole flow before you ship
A verification flow has more moving parts than it looks — send, deliver, auto-read, type, verify, resend, fallback — and the failures live in the seams between them. Test the whole path before real users hit it, not just the happy case.
Use a test API key against the same endpoints. Good providers accept and verify test sends without touching the operator network, so you can wire up send, verify, your webhook handler, and your retry interface end to end, then switch to a live key once your DLT template is approved. Nothing about the calls changes between test and live except the key, so what you test is what you ship.
Exercise the unhappy paths deliberately. Enter a wrong code and confirm the mismatch status and the attempts-remaining count behave. Let a code expire and check the expired state. Request too many codes and confirm the rate limit kicks in with a clear signal. Trigger the voice fallback and make sure your verify call handles a code that arrived by call exactly as one that arrived by SMS. Each of these is a place a real user will eventually land, and each is cheap to get right in testing and expensive to get wrong in production.
Finally, watch the metrics once you're live. Track send-to-verify completion rate by channel and device; a sudden drop usually means a deliverability issue or a broken auto-read integration, and the per-attempt log — channel, status, timing — is what tells you where to look before it costs you signups.
The short version
Let the provider generate, store, expire, and verify the code; keep the TTL short; cap attempts and rate-limit sends; handle resend and voice fallback so delivery failures don't lock users out; use auto-read to remove the typing step; and keep the message lean, link-free, and on its registered template. Do those, and you get a verification flow that's hard to attack and easy to pass — which is exactly the balance an OTP is supposed to strike. And because most of it — generation, storage, expiry, and verification — is handled by the provider rather than your code, the secure path is also the one with less to build and maintain, which is a rare and welcome alignment.