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

Canonical: https://mobilevalidate.com/blog/otp-fraud-prevention-checks-before-sending-a-code · Last updated: 2026-09-25

![Cover: OTP fraud prevention: the checks to run before you send a code](https://mobilevalidate.com/og/blog/otp-fraud-prevention-checks-before-sending-a-code.png)


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-25 · Category: Fraud prevention · Tags: OTP, Sign up fraud, Phone verification, Fraud prevention, API

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](/blog/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](https://csrc.nist.gov/pubs/sp/800/63/b/4/final)). In 2023 Twitter restricted SMS two-factor authentication after seeing it "used - and abused - by bad actors" ([Twitter, 2023](https://blog.x.com/en_us/topics/product/2023/an-update-on-two-factor-authentication-using-sms-on-twitter)). A pre-send check is how you gather some of those risk indicators cheaply.

## What does the pre-send pipeline look like?

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

| Step | What happens | Cost | Blocks on failure? |
|---|---|---|---|
| 1. Throttle | Rate limits per number, prefix, IP, device and country; bot challenge | Free | Yes |
| 2. Normalize | Convert to [E.164](/glossary/e164); reject numbers that can't exist | Free | Yes (ask for a correction) |
| 3. Check | One lookup: line type, messenger presence, reputation | Per conclusive check | Only on clear negatives |
| 4. Decide | Apply a rule table, combine with your session signals | Free | Send / step up / refuse |
| 5. Record | Store the decision, `checked_at` and verification outcome | Free | No |

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 answers | Useful for | Coverage |
|---|---|---|---|
| Carrier lookup (`carrier`) | What [line type](/glossary/line-type) is this, which carrier, which country? | Stopping SMS to premium-rate or fixed lines; flagging VoIP | All 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 channel | All countries |
| Spam reputation (`spam`) | Does the number appear in spam and nuisance reports? | Flagging numbers with regulator actions or fraud reports | US, CA, DE; limited access |
| Live network status (`hlr`) | Is the number reachable on its network right now? | Skipping switched-off or unassigned numbers | Coming 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](/services/carrier-lookup) shows a porting hint today (`original_carrier` differs from `carrier`). The [HLR lookup](/services/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:

```bash
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.

```js
// 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](/use-cases/otp-and-signup-fraud) 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](https://csrc.nist.gov/pubs/sp/800/63/b/4/final)).

| Your signal | Why it matters | Combine with |
|---|---|---|
| New device for an existing account | Classic account-takeover pattern | A new number on the account → step up |
| Phone number changed in the last days | Attackers swap numbers before resets | Porting hint from the carrier lookup |
| Country of the number ≠ IP country ≠ account country | Weak on its own, strong in combination | `country` from the lookup |
| Many sign-ups sharing a number prefix | Pumping or bulk fake accounts | Line type, messenger presence |
| Code requested but never entered | Pumping or a bot | Verification 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](/use-cases/account-security) 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](/pricing).

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](/docs/test-mode). 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](https://csrc.nist.gov/pubs/sp/800/63/b/4/final) — NIST, 2025
2. [An update on two-factor authentication using SMS on Twitter](https://blog.x.com/en_us/topics/product/2023/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.
