Generate codes with real entropy
Use a cryptographically secure random generator, never a predictable seed or a timestamp-derived value. Six digits is the usual balance of security and usability; shorter codes need stricter rate limits to stay safe.
Never send the same code twice, and never make a code guessable from a user id or sequence. The whole security of SMS OTP rests on the code being unpredictable within its short lifetime.
- Use a CSPRNG (e.g. secrets/crypto libraries), not a general-purpose PRNG.
- 6 digits is standard; if you use 4, tighten rate limits and expiry hard.
- One code per request; invalidate previous codes when a new one is issued.
Store codes safely, never in plaintext
Treat an OTP like a short-lived password. Store a hash of the code (with the identifier and expiry) rather than the raw value, so a database leak does not expose live codes. Compare using a constant-time function to avoid timing attacks, and delete the record as soon as it is used or expires.
- Hash the code before storing; compare in constant time.
- Bind each code to a single user/session and purpose.
- Delete on success, expiry, or after the attempt cap is hit.
Set a short expiry window
Codes should live for minutes, not hours. A 5β10 minute expiry is the common range: long enough for a delayed SMS to still arrive, short enough that a leaked code is usually already dead. Display the remaining validity to the user so a slow message does not read as a broken flow.
Pair a short expiry with a resend cooldown, or users will hammer resend when a message is delayed and multiply your SMS cost.
Rate limit every dimension
Rate limiting is what stops both brute-force guessing of codes and abuse of your send endpoint (which costs you money and can get your sender flagged). Limit per phone number, per IP, and per account, on both sending and verifying.
- Verification attempts: cap tries per code (e.g. 5) then invalidate and force resend.
- Send requests: cooldown between sends and a daily cap per number.
- Global guards: per-IP and per-account limits to blunt automated abuse.
- Add exponential backoff or CAPTCHA when limits are repeatedly hit.
Handle retries and fallback channels
SMS is best-effort, so a good flow assumes some messages will not arrive. Offer a resend after a cooldown, and after one or two failed SMS attempts, escalate to a fallback channel β a voice call that reads the code, WhatsApp, or email β which is exactly what managed Verify APIs automate.
Make the failure states human: a clear "didn't get it?" path, a way to change the number, and support contact after repeated failure.
- 1
First attempt: SMS
Send the code and start a visible countdown with a resend cooldown.
- 2
Resend on request
Allow a resend after the cooldown; invalidate the previous code when you issue a new one.
- 3
Escalate channel
After repeated SMS failure, offer voice or an alternate channel to deliver the same verification.
- 4
Offer an exit
Let the user correct their number or reach support instead of dead-ending.
Improve UX without weakening security
Small touches raise completion rates: use the WebOTP API and SMS autofill formats so mobile browsers can read the code, keep message wording consistent (some platforms cache trusted senders), and never include anything but the code and your app name in the message. Avoid links in OTP messages β they train users to click, and they trip carrier spam filters.
- Support WebOTP / one-time-code autofill on mobile.
- Keep the message minimal: app name + code, no marketing, no links.
- Localize the message text but keep the code format stable.