SMS pumping fraud prevention comes down to one rule: never let an anonymous request trigger an SMS without limits. Attackers abuse open "send code" endpoints to fire large numbers of messages at phone number ranges that earn them a share of the fees, and you pay the bill. The defence is layered: restrict the countries you send to, rate-limit by phone number, number range, IP and session, put a bot check in front of the send action, and watch your verification conversion rate. Below is how each layer works, plus a small rate limiter you can adapt.
What SMS pumping is
SMS pumping, also called SMS toll fraud or artificially inflated traffic (AIT), targets any feature that sends a text on request: sign-up codes, login OTPs, password resets, "text me a download link" widgets. The pattern:
- The attacker finds an endpoint that texts any number submitted to it.
- A script submits numbers in ranges where a complicit party in the delivery chain earns money per message, often in expensive destinations.
- The fees are shared; nobody ever enters a code.
Each message is cheap on its own, so the damage tends to show up on an invoice rather than as an outage.
Signs you're being pumped
- Sudden volume spikes to countries where you have few real users
- Many numbers with the same prefix, differing only in the last few digits
- A sharp drop in verification completion for one country or prefix
- Requests with no prior page views, identical user agents or rotating IPs
Layer 1: geo permissions
The cheapest control is an allowlist. If you serve a handful of countries, refuse everything else before calling your SMS provider, and disable unused destinations in the provider's console too.
Be careful with +1. It isn't only the US and Canada: the North American Numbering Plan includes many Caribbean countries, so a prefix check would let them all through. Check the region instead:
import phonenumbers
ALLOWED_REGIONS = {"US", "CA", "GB", "NL", "SE"}
def region_allowed(e164: str) -> bool:
number = phonenumbers.parse(e164, None)
return phonenumbers.region_code_for_number(number) in ALLOWED_REGIONS
Layer 2: rate limits on several keys
A per-IP limit alone is easy to dodge with proxies. Limit several dimensions at once:
| Key | What it stops | Starting point |
|---|---|---|
| Phone number | Repeated sends to one number | a few per hour |
| Number range | Sequential-number attacks | tens per hour |
| IP address | Single-source scripts | around 10 per hour |
| Session | Sends without a real browsing session | a few per session |
| Global | Worst-case spend | above your normal peak |
Tune them to your traffic. Here's a fixed-window limiter in Python with Redis that checks every key in one round trip:
import time
import redis
r = redis.Redis(decode_responses=True)
# (scope, max sends, window in seconds)
RULES = [
("phone", 3, 3600),
("range", 20, 3600),
("ip", 10, 3600),
("global", 2000, 3600),
]
def sms_allowed(phone: str, ip: str) -> bool:
subjects = {
"phone": phone,
"range": phone[:-3], # same number minus its last 3 digits
"ip": ip,
"global": "all",
}
now = int(time.time())
pipe = r.pipeline()
for scope, _, window in RULES:
key = f"otp:{scope}:{subjects[scope]}:{now // window}"
pipe.incr(key)
pipe.expire(key, window)
counts = pipe.execute()[0::2] # keep INCR results, skip EXPIRE results
return all(count <= limit for count, (_, limit, _) in zip(counts, RULES))
A few notes:
- Every attempt counts, including refused ones, so a script hammering one number stays blocked.
- Fixed windows allow bursts at boundaries; a sliding window or token bucket is smoother.
- Log which rule fired so you can tune the limits later.
Layer 3: a bot check before the send
Put a CAPTCHA or invisible bot challenge in front of the "send code" action, not after it. You can show it only when risk signals appear: a new IP, an unusual country, several recent sends.
Make the SMS the last step of the form. Validate everything else first and require a token issued when the page loads, so a script can't simply POST to your send endpoint.
Layer 4: monitor conversion, not just volume
The clearest signal is the ratio of verified codes to sent codes. Genuine users enter most codes they request; pumped traffic enters almost none. Track it per country every hour (PostgreSQL):
SELECT country,
COUNT(*) AS sent,
COUNT(*) FILTER (WHERE verified_at IS NOT NULL) AS verified,
ROUND(100.0 * COUNT(*) FILTER (WHERE verified_at IS NOT NULL) / COUNT(*), 1) AS pct
FROM otp_requests
WHERE created_at > now() - interval '1 hour'
GROUP BY country
ORDER BY sent DESC;
Alert when a country falls well below its usual rate, and keep a kill switch that stops sending to one country without a deploy. Many SMS providers also offer built-in fraud protection; treat it as one layer, not the whole defence.
SMS pumping fraud prevention without blocking real users
After tightening limits, confirm that real people still get their codes. Send to genuine numbers in every country you allow; the free public numbers on Temp SMS are handy for quick manual checks of delivery and message text. If several testers use the same shared number, your per-number limit may kick in. That's the limiter working, not a bug.
FAQ
Is SMS pumping the same as SMS spam?
No. Spam targets the recipient. Pumping targets the sender's wallet by generating paid traffic nobody reads.
Does a CAPTCHA alone stop SMS pumping?
It raises the attacker's cost, but solving services exist. Combine it with geo rules, rate limits and monitoring.
Which endpoints are at risk?
Any that send an SMS on request: sign-up, login, password reset, phone number change and "send me a link" forms.
More in this series on SMS verification:
Top comments (0)