// OTP security

OTP implementation best practices: codes, expiry, rate limits, and fallback

A one-time passcode looks trivial β€” generate a number, text it, check it. But the details are where account-takeover bugs live. This guide covers the decisions that make an OTP flow genuinely secure and usable: code entropy, storage, expiry windows, rate limiting, retries, and fallback channels.

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

    First attempt: SMS

    Send the code and start a visible countdown with a resend cooldown.

  2. 2

    Resend on request

    Allow a resend after the cooldown; invalidate the previous code when you issue a new one.

  3. 3

    Escalate channel

    After repeated SMS failure, offer voice or an alternate channel to deliver the same verification.

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

Frequently asked questions

How long should an OTP be valid?+

Five to ten minutes is the common window. It is long enough to survive a delayed SMS but short enough that a leaked code is usually already expired. Always show the remaining time so a slow message doesn't look like a broken flow.

How many verification attempts should I allow?+

Cap attempts per code at around five, then invalidate the code and require a fresh send. Combine that with per-number, per-IP, and per-account rate limits on both sending and verifying to stop brute force and send-endpoint abuse.

Should I put a link in the OTP message?+

No. Keep OTP messages to just your app name and the code. Links train users to click, can leak the code through referrers, and frequently trigger carrier spam filtering that hurts deliverability.

Is SMS OTP secure enough on its own?+

SMS OTP is a meaningful step up from passwords alone but is vulnerable to SIM swaps and interception. For high-value accounts, offer stronger factors like passkeys or TOTP and treat SMS as a fallback β€” see the OTP Alternatives guide.

EdgeGigs

Get your OTP flow implemented correctly the first time.

Hire a vetted developer on EdgeGigs to build secure code generation, expiry, rate limiting, and fallback into your web or mobile app.

Find a developer on EdgeGigs β†’