SMS Verification Best Practices: Designing OTP Flows That Hold Up

Jan 16, 2026

SMS one-time passcodes are the most-deployed second factor on the internet and the weakest one still in widespread use. NIST has discouraged SMS as a restricted authenticator since 2016, and it remains everywhere because it's the only factor essentially every user already has.

If you're building or operating an OTP flow, the practical question isn't whether SMS is ideal. It's how to run it so that the failure modes are the ones you chose. This article covers defensive design—it isn't about circumventing anyone's protections.

The Threat Model, Briefly

Design against these specifically rather than "fraud" in the abstract:

  • SIM swap and number takeover — an attacker convinces a carrier to move the victim's number to their device. Nothing in your OTP flow detects this; only recency signals and step-up checks help.
  • Real-time phishing relay — a proxy site collects the code from the victim and replays it within its lifetime. This is the dominant attack against SMS OTP today, and code lifetime is your main lever against it.
  • OTP interception on a compromised device — notification previews, malicious apps with SMS permission, and synced message histories.
  • Brute force and replay — guessing a 6-digit code, or reusing a captured one.
  • Enumeration — using the OTP endpoint to discover which phone numbers have accounts.
  • SMS pumping (toll fraud) — an attacker drives sends to premium-rate ranges they profit from. This one costs you money directly and is frequently overlooked until the bill arrives.

Rate Limiting That Actually Works

Most rate limiting fails because it's applied on one dimension and attackers rotate the others.

Limit on several keys simultaneously. Per phone number, per account, per IP, per device fingerprint, and globally per destination country. An attacker who rotates IPs still hits the per-number ceiling; one who rotates numbers hits the per-IP ceiling.

Apply progressive delay, not a hard wall. Escalating backoff—30 seconds, then two minutes, then ten—degrades an automated attack's throughput without locking out a legitimate user who genuinely mistyped.

Cap by destination country. SMS pumping concentrates on specific number ranges. A per-country send ceiling, alerted on when approached, catches this before it becomes a large invoice.

Introduce step-up friction on anomaly, not on volume. CAPTCHA on every request trains users to ignore it. CAPTCHA when the risk score crosses a threshold preserves its signal value.

Separate the send limit from the verify limit. These are different attacks. Sends cost money and enable pumping; verify attempts are brute force. Lock the code after a small number of failed attempts—five is a common ceiling—independently of how many sends you allow.

Code Lifetime, Entropy, and Binding

Keep codes short-lived. Minutes, not hours. The window is your primary defense against real-time phishing relay: a code that expires in five minutes gives an attacker five minutes to use it. Shorter is safer, but going below about two minutes starts generating support load from users on slow carrier routes.

Single-use, invalidated on reissue. When a user requests a new code, the old one must die immediately. Leaving previous codes valid multiplies the attack surface for no user benefit.

Six digits minimum. Combined with a five-attempt cap, six digits puts brute force out of reach. Four-digit codes are guessable within a generous attempt budget.

Bind the code to context. Store what the code was issued for and verify it on redemption:

  • The action (sign-up vs login vs password reset vs a payment confirmation)
  • The session or device that requested it
  • The phone number and region in normalized E.164 form

Binding to action matters most. A code issued to confirm an email change should not be redeemable to authorize a transfer.

Say what the code is for in the message body. "123456 is your code to confirm a $400 transfer" gives a phishing victim a chance to notice the mismatch. A bare six digits gives them nothing.

Delivery UX: Where Support Tickets Come From

Users experience OTP in binary—it worked, or the product is broken. Most of that perception is set by the interface, not by delivery.

Set the waiting expectation. "Codes usually arrive within 30 seconds" prevents the resend spiral, which itself causes throttling and makes delivery genuinely worse.

Gate resend behind a visible countdown. A greyed-out button with a timer stops the behavior that hurts the user most.

Don't leak account existence in errors. "If an account exists for this number, we've sent a code" is the standard phrasing. Differentiating between "no account" and "code sent" hands attackers a free enumeration oracle.

Support paste and autofill. Use autocomplete="one-time-code" on the input and the origin-bound WebOTP format for the message body. Splitting the code into six separate boxes that reject a paste is an accessibility problem as much as a usability one.

Provide a fallback path. Voice call delivery, email, or an authenticator enrollment option. Some routes silently fail for specific carrier and region combinations, and a user with no alternative simply leaves.

Never expose the full number. Mask to the last two to four digits in any confirmation screen.

Instrument by Region and Carrier

Aggregate delivery rate is a number that hides every problem worth finding.

Track send success, delivery receipt, and verification completion broken out by destination country and carrier. Upstream routing changes show up as a single region collapsing while the global average barely moves. Alert on per-segment deviation from that segment's own baseline, not on the overall figure.

Also worth tracking: time-to-verify distribution. A rising p95 usually means a route degraded before it fails outright.

Data Retention: Store Less

Keep what you need to operate support and investigate incidents, and no more:

  • Do keep: timestamps, delivery status, destination country, a hashed or truncated number reference, and an audit trail of verification outcomes.
  • Don't keep: message bodies containing live codes, beyond the verification window. There's no operational reason and it's a liability in any breach.
  • Hash codes at rest. A code sitting in plaintext in a database or a log is an unnecessary exposure. Log the code's existence, never its value.
  • Set an actual TTL. Verification records that persist indefinitely accumulate a phone-number dataset you didn't intend to build.

How USPhoneGen handles this: Privacy Policy · Terms of Service

Users on Virtual Numbers

A meaningful share of your users will verify with virtual or online-reception numbers—for privacy, for testing, or because they're abroad. Your system should stay safe without treating them as guilty by default.

Decide your line-type policy deliberately, and state it. If you block VoIP numbers, say so in the error message. A generic "verification failed" on a deterministic policy rejection sends the user into a retry loop that can never succeed and lands them in your support queue.

Don't punish carrier latency. Regional variance is normal. Timeouts and retry logic should accommodate slow routes rather than treat them as fraud signals.

Weigh line type as one input among several. Number history, IP reputation, device signals, and velocity all carry information. A blanket VoIP block is a blunt instrument that costs you legitimate users; it's the right call for a payment flow and usually the wrong one for a newsletter.

Re-verify at the moments that matter, not continuously. Checking again at a withdrawal or a credential change is proportionate. Re-checking a dormant account and locking it out is how you lose users who did nothing wrong.

Background on how line-type classification works and why it produces the rejections your users report: Non-VoIP vs VoIP Numbers

Plan the Exit From SMS

SMS OTP is a floor, not a destination. Design so you can move users off it:

  • Offer TOTP or passkeys alongside SMS and prompt enrollment after a successful login rather than during sign-up, where added friction costs conversion.
  • Never let SMS silently override a stronger factor. If a user has a passkey, SMS as an unconditional fallback reduces their security to SMS.
  • Treat number changes as high-risk events. Require the existing factor, and impose a cooling-off period before the new number can be used for recovery.

Frequently Asked Questions

How long should an OTP stay valid?
Two to ten minutes. Five is a reasonable default. Shorter reduces the phishing relay window; too short generates failures for users on slow routes.

How many verification attempts should I allow?
Around five per code, then invalidate it and require a new send. Track failed attempts per account separately to catch distributed guessing.

Should I block VoIP numbers?
It depends on what the account can do. For payments or anything under KYC obligations, yes. For a general consumer product, a blanket block costs legitimate users—weigh line type alongside other signals instead.

What's the single highest-value control?
Multi-dimensional rate limiting with per-country send caps. It addresses brute force, enumeration, and toll fraud simultaneously.

Is SMS OTP still worth deploying in 2026?
As a baseline for users who have nothing else, yes. As the strongest factor you offer, no. Ship it alongside a path to TOTP or passkeys.

How do I stop SMS pumping?
Per-country send ceilings with alerting, blocking destination ranges you don't serve, and progressive friction on new accounts. Watch cost per verified user by country—pumping shows up there first.

References

Admin

Admin