Fraud prevention

OTP fraud prevention: the checks to run before you send a code

A practical pre-send pipeline for one-time passcodes: normalize, check line type, messenger presence and reputation in one request, then decide.

By Published 8 min read

On this page

Before you send a one-time passcode, check the number: normalize it, look up its line type, see whether it has a messenger account and, where available, whether it has spam reports. One API request can run all of these. Your code then sends, steps up or refuses, before you pay for a message.

Why check the number before sending a one-time passcode?

Because the send is the expensive, irreversible step. Once an SMS has left, you've paid for it, and if the number belongs to an attacker you've also started an account they control.

Three kinds of abuse hit OTP endpoints:

  • SMS pumping. Bots trigger codes to number ranges that earn the attacker a share of the fee. See SMS pumping: how it works and how to stop it.
  • Fake account creation. Cheap, disposable numbers pass verification and then farm sign-up bonuses, referral credits or free trials.
  • Account takeover. An attacker adds or swaps the phone number on someone else's account so that future codes go to them.

Standards bodies share the concern. NIST's authentication guidance classes one-time codes over the phone network as a restricted authenticator, and says verifiers should consider risk indicators such as "device swap, SIM change, number porting, other abnormal behavior" before using the phone network to deliver a code (NIST, 2025). In 2023 Twitter restricted SMS two-factor authentication after seeing it "used - and abused - by bad actors" (Twitter, 2023). A pre-send check is how you gather some of those risk indicators cheaply.

A sign-up form with a one-time code; one number passes the checks while a VoIP number and a risky number are stopped.A sign-up form with a one-time code; one number passes the checks while a VoIP number and a risky number are stopped.
Check the number before you send the code, and hold back VoIP and high-risk numbers.

What does the pre-send pipeline look like?

It is five steps between "user entered a number" and "code sent":

StepWhat happensCostBlocks on failure?
1. ThrottleRate limits per number, prefix, IP, device and country; bot challengeFreeYes
2. NormalizeConvert to E.164; reject numbers that can't existFreeYes (ask for a correction)
3. CheckOne lookup: line type, messenger presence, reputationPer conclusive checkOnly on clear negatives
4. DecideApply a rule table, combine with your session signalsFreeSend / step up / refuse
5. RecordStore the decision, checked_at and verification outcomeFreeNo

Steps 1 and 2 are cheap and can remove much of the bot traffic. Step 3 is where the paid checks run. Put it after the throttle, so a bot can't make you pay for checks either. The whole pipeline should add no more than a few seconds to the user's wait, and it must degrade gracefully: if the check is slow or unavailable, the user still gets a code under your default rules.

Which checks should you combine, and what does each one tell you?

Each check answers a different question. None proves identity. Together they give a risk engine something concrete.

Check (alias)Question it answersUseful forCoverage
Carrier lookup (carrier)What line type is this, which carrier, which country?Stopping SMS to premium-rate or fixed lines; flagging VoIPAll countries (beta; NO_DATA is free)
Messenger checks (whatsapp, telegram, viber)Does the number have an account on this app?Evidence the number was used on a phone; alternative delivery channelAll countries
Spam reputation (spam)Does the number appear in spam and nuisance reports?Flagging numbers with regulator actions or fraud reportsUS, CA, DE; limited access
Live network status (hlr)Is the number reachable on its network right now?Skipping switched-off or unassigned numbersComing soon

A messenger account matters because each app verified the number at sign-up at some point. That makes a number with an account more likely to be in real use. It doesn't say the person in front of you owns it.

Porting and SIM changes, two of NIST's examples, need live network data. The carrier lookup shows a porting hint today (original_carrier differs from carrier). The HLR lookup, with a ported flag, is coming soon.

How do you run several checks in one request?

Send the checks as a list. You get one result per number and check, in request order. Here is a real test-mode request for three test numbers with three checks:

Shell
curl https://api.mobilevalidate.com/v1/lookup \
  -H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-7f3a1c" \
  -d '{"numbers": ["+447700900001", "+447700900002", "+447700900003"],
       "checks": ["carrier", "whatsapp", "spam"], "wait": 5}'

Response (excerpt of the first result, test mode):

JSON
{
  "e164": "+447700900001", "country": "GB", "number_status": "valid",
  "checks": {
    "network.carrier": {"status": "completed", "registered": true,
      "attributes": {"line_type": "mobile", "carrier": "Test Carrier", "country": "GB"},
      "checked_at": "2026-09-25T16:06:07.732Z", "cached": false, "billed": false, "reason": null},
    "whatsapp.registered": {"status": "completed", "registered": true,
      "checked_at": "2026-09-25T16:06:07.732Z", "cached": false, "billed": false, "reason": null},
    "number.spam": {"status": "completed", "registered": true,
      "attributes": {"risk_level": "high", "risk_score": 95, "reason_regulator": true,
        "reason_community": true, "reason_unassigned": false, "voip_range": false,
        "top_category": "robocall", "sources": 2},
      "billed": false}
  }
}

The other two numbers show the paths your code must handle. +447700900002 returns carrier unknown with reason: "NO_DATA", WhatsApp registered: false and spam risk_level: "no_reports" (we hold no reports for it, which doesn't mean the number is safe). +447700900003 returns unknown with reason: "UPSTREAM_TIMEOUT" for every check. Test keys are free, and billed is always false in test mode. Live keys bill each conclusive check.

Limits worth knowing: up to 100 numbers per lookup, up to 20 checks per request, and numbers × checks capped at 2,000 per lookup. An OTP request is one number, so these never bind.

What happens when a check doesn't finish in time?

Some answers take longer than your sign-up flow can wait. The API returns what it has when wait runs out and marks the rest as pending. A real test-mode example with wait: 1:

Text
status: pending   next: {"poll_url": "/v1/lookups/lkp_0VWFWI29sBicsxfDEYuZ", "poll_after_ms": 2000}
+447700900002  network.carrier: unknown (NO_DATA)    whatsapp.registered: completed, registered false
+447700900004  network.carrier: pending              whatsapp.registered: pending

Don't hold the user for the pending answer. Send the code under your default rules and fetch the lookup later with GET /v1/lookups/{id} (or receive a lookup.completed webhook). Use the late answer for after-the-fact review: flag the new account, limit what it can do until it's verified, or queue it for a fraud analyst.

How do you turn results into a decision?

Write the rules as a small, explicit function. Explainable rules are easier to tune and to defend when a customer complains.

JavaScript
// Decide what to do with one number. r = one item from results[].
function otpDecision(r) {
  if (r.number_status === "invalid_number") return { action: "ask_correction" };
  const c = r.checks ?? {};
  const line = c["network.carrier"]?.attributes?.line_type;       // undefined when unknown
  const wa = c["whatsapp.registered"]?.registered;                // true | false | null
  const risk = c["number.spam"]?.attributes?.risk_level;          // undefined outside US/CA/DE

  if (["premium_rate", "shared_cost", "uan"].includes(line)) return { action: "refuse" };
  if (risk === "high") return { action: "step_up", why: "spam_high" };
  if (line === "fixed_line" || line === "toll_free") return { action: "voice_or_other" };
  if (line === "voip" && wa !== true) return { action: "step_up", why: "voip_no_messenger" };
  return { action: "send" };                                      // includes every unknown/pending case
}

Note the last line. Missing data always falls through to send under your normal rate limits. A check that timed out must never lock out a real user. The rule table in the OTP and sign-up fraud use case lists the same rules in prose.

"Step up" can mean several things: a CAPTCHA, an e-mail confirmation, a voice call instead of SMS, a lower send limit for that number, or a hold on sign-up rewards until the account has some history.

Which of your own signals should you add?

The number check covers the number. Your own data covers the session, and NIST's list of risk indicators ("device swap, SIM change, number porting, other abnormal behavior") mixes both (NIST, 2025).

Your signalWhy it mattersCombine with
New device for an existing accountClassic account-takeover patternA new number on the account → step up
Phone number changed in the last daysAttackers swap numbers before resetsPorting hint from the carrier lookup
Country of the number ≠ IP country ≠ account countryWeak on its own, strong in combinationcountry from the lookup
Many sign-ups sharing a number prefixPumping or bulk fake accountsLine type, messenger presence
Code requested but never enteredPumping or a botVerification rate per country

Account changes deserve stricter rules than first sign-ups. When someone changes the number on an existing account, confirm through the old number or e-mail first. The account security use case walks through that flow.

What does it cost, and how do you keep it low?

You pay per check, and only for conclusive answers. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate). Spam reputation is an exception worth knowing: every level, including no_reports, is a conclusive answer and is billed. Current rates are on the pricing page.

Ways to keep the bill proportional:

  • Throttle first. Checks after the rate limiter can't be run up by a bot.
  • Check once per number. A repeat check within the freshness window is served from your account's cache for free. That covers the "resend code" button.
  • Use max_cost. It caps what one request can cost. If the maximum possible cost is higher, the request is refused before anything is checked.
  • Pick checks per flow. First sign-up: carrier plus one messenger. Number change on a high-value account: add spam reputation where available.
  • Send an Idempotency-Key. A retried request after a network error returns the first answer instead of running and billing the checks again.

How do you know the pipeline works?

Measure it like any fraud control, with a baseline and a comparison.

  1. Log each decision (send, step_up, refuse, voice_or_other) with the reason and checked_at. Don't store the full response longer than you need it.
  2. Track the verification rate (codes entered ÷ codes sent) per decision. send should verify at your normal rate. refuse and step_up should have been low before the rules existed.
  3. Watch false positives: users who were stepped up and then completed verification. If that share is high, the rule is too strict.
  4. Track SMS spend per country before and after.
  5. Revisit the rules every quarter, and whenever an attack changes shape.

What are the key takeaways?

  • Put the number check between the rate limiter and the send. It is the last cheap moment before an irreversible cost.
  • One request can run several checks: line type, messenger presence and, where enabled, spam reputation.
  • Refuse only on clear negatives such as premium-rate lines. Step up on combinations. Send on missing data.
  • Handle pending and unknown as "no information": send under default rules and review later.
  • Combine number facts with your own session signals, following NIST's risk indicators, and measure the verification rate per decision.

Start in test mode with the numbers on the test mode page. They cover every path above, including timeouts and pending answers, at no cost.

Sources

  1. NIST SP 800-63B-4: Digital Identity Guidelines — Authentication and Authenticator Management — NIST, 2025
  2. An update on two-factor authentication using SMS on Twitter — Twitter (now X), 2023

Frequently asked questions

How long should a sign-up flow wait for the checks?

Set wait to a few seconds, for example 3–5. Anything not back in time comes back as pending. Your flow should then continue with its default rules rather than keep the user waiting.

Should an unknown result block the code?

No. Unknown means no conclusive answer, for example a timeout or no data for that number. It has registered set to null, it isn't charged, and the flow should behave as if the check had not run.

Which checks are worth running on every OTP request?

Format validation (free) and line type from the carrier lookup catch a lot of common abuse. Add a messenger check if you deliver codes on a messenger, and spam reputation where it is enabled for your account and the number is from the US, Canada or Germany.

Does this replace rate limiting and bot protection?

No. The checks add facts about the number. Rate limits, bot protection and a country allow-list still do most of the work against automated attacks.

All articles

Know before you send.

Tell us about your use case. We review every request and set you up with test and live keys.