Skip to main content

Command Palette

Search for a command to run...

The SMS bill nobody planned for: when a signup form sends a code to any number typed in

Rate limits, reserved ranges, country allowlists and spend caps for Node and PHP, with tested snippets

Updated
•10 min read•View as Markdown
E
I'm building Orisift, a developer API that checks phone numbers, email domains and IP addresses against reference data, and says what it couldn't find out. I write about the small standards details that decide how forms behave.

If your signup form sends a one-time code by SMS, you have built a button that spends money on behalf of whoever presses it. Most of the time the person pressing it wants to sign up. Sometimes they want the message to be sent, and nothing else.

That second case has a name: SMS pumping. Twilio describes it plainly in its docs: someone uses a phone number field to trigger messages to a range of numbers controlled by a particular mobile network, and takes a share of the revenue those messages generate (Twilio, SMS pumping). Nobody ever enters the code. Your form did its job perfectly, and the bill is the only evidence.

This post is the set of checks I would put in front of any SMS send on a signup form. None of them is clever. The point is that they run before the provider is called, because once the message is sent the money has gone.

What the traffic looks like

Pumping rarely looks like one person hammering one number. The usual signs are:

  • many sends to numbers that share a long prefix, often in a country you have never had a customer in;
  • sends spread across many IP addresses, so a per IP limit alone does not catch it;
  • almost no codes ever entered;
  • bursts at odd hours, then nothing.

So the defences have to work on the number, the prefix and the country, not only on the person.

Step one: parse to E.164 and refuse impossible numbers

Everything else depends on having one canonical form of the number. Parse what was typed, using the country the form is showing, and turn it into E.164 (+447400123456). If the library says the number cannot exist, stop there. There is no reason to pay to send a code to 12345.

Order matters a little. Check whether the number is possible, then the country, then the reserved ranges, and only then whether it is valid in the numbering plan. That way your logs say why a number was refused, which helps when someone emails support.

Step two: allowlist the countries you serve

If your customers are in the UK, Ireland, North America and Australia, there is no reason for your form to send codes anywhere else. An allowlist is the single most effective change here, because pumping depends on reaching expensive destinations. Twilio says the same thing: disable the countries you do not plan to send to (Twilio, geo permissions).

Do it twice: in your code, so the provider is never called, and in the provider's console, so a bug in your code cannot undo it.

Step three: refuse reserved and fictional ranges

Every numbering plan sets aside numbers for films, adverts and documentation. Nobody can hold them, so a code sent there is wasted, and seeing them at signup usually means test data or someone filling the form in by hand without caring.

  • North America: 555-0100 to 555-0199 in any area code are reserved as fictitious, non working numbers (NANPA).
  • UK: Ofcom publishes drama ranges, including 07700 900000 to 900999 for mobiles, 020 7946 0000 to 0999 for London, and 01632 960000 to 960999 (Ofcom).
  • Australia: ACMA lists geographic ranges such as (02) 5550 xxxx and (02) 7010 xxxx, plus a set of individual 0491 mobile numbers (ACMA, archived page). ACMA lists single mobile numbers, not a block, so match them exactly.

These ranges are small. Blocking them will not stop pumping on its own, but it costs nothing and removes a class of sends that can never succeed.

Step four: rate limit per number, per IP and per prefix

Three counters, each with its own window:

  • per number: a few codes an hour is plenty for someone who mistyped or lost signal;
  • per IP: catches the lazy cases;
  • per prefix: the one that matters for pumping. Take the country code and the next few digits and count sends across all users. Twenty codes in ten minutes to one prefix you have never seen before is worth refusing and alerting on.

Twilio's own guidance is in the same spirit: no more than one message per so many seconds to the same range or prefix, and limits by user, IP or device (Twilio).

The Node version

Tested with Node 20.19.2 and libphonenumber-js 1.13.15. The counters are in memory to keep the logic readable; in production put them in Redis with INCR and EXPIRE.

// sms-guard.mjs: decide whether a signup may send an SMS code to this number.
import { parsePhoneNumberFromString } from 'libphonenumber-js/max'

// Countries you actually serve. Everything else is refused before any send.
const ALLOWED = new Set(['GB', 'US', 'CA', 'AU', 'IE'])

// Ranges set aside for fiction and testing, as E.164 prefixes or patterns.
const RESERVED = [
  /^\+1\d{3}5550(1\d\d)$/,           // NANP 555-0100 to 555-0199, any area code
  /^\+447700900\d{3}$/,              // Ofcom drama mobile 07700 900000 to 900999
  /^\+44(11[3-8]|121|131|141|151|161)4960\d{3}$/, // Ofcom drama, 0113 to 0161
  /^\+442079460\d{3}$/,              // London 020 7946 0000 to 0999
  /^\+441914980\d{3}$/,              // Tyneside 0191 498 0000 to 0999
  /^\+442896496\d{3}$/,              // Northern Ireland 028 9649 6000 to 6999
  /^\+442920180\d{3}$/,              // Cardiff 029 2018 0000 to 0999
  /^\+441632960\d{3}$/,              // 01632 960000 to 960999
  /^\+61[2378](5550|7010)\d{4}$/,    // ACMA geographic (0X) 5550 xxxx and 7010 xxxx
]

// ACMA lists individual mobile numbers for fiction rather than a block.
const ACMA_MOBILE = new Set(['570006','570110','570156','570157','570158','570159','570313','570737',
  '571266','571491','571804','572549','572665','572983','573087','573770','574118','574632',
  '575254','575789','576398','576801','577426','577644','578148','578888','578957','579212',
  '579455','579760'].map((n) => '+61491' + n))

export function checkNumber(input, country) {
  const phone = parsePhoneNumberFromString(String(input), country)
  if (!phone || !phone.isPossible()) return { ok: false, reason: 'impossible' }
  if (!phone.country || !ALLOWED.has(phone.country)) return { ok: false, reason: 'country_not_served' }
  const e164 = phone.number
  if (ACMA_MOBILE.has(e164) || RESERVED.some((re) => re.test(e164))) return { ok: false, reason: 'reserved_range', e164 }
  if (!phone.isValid()) return { ok: false, reason: 'not_in_numbering_plan', e164 }
  return { ok: true, e164 }
}

// Fixed window counters. In production use Redis INCR with EXPIRE; this keeps the logic visible.
export function makeLimiter(now = () => Date.now()) {
  const buckets = new Map()
  return function hit(key, limit, windowMs) {
    const t = now()
    const b = buckets.get(key)
    if (!b || t >= b.reset) { buckets.set(key, { count: 1, reset: t + windowMs }); return true }
    b.count += 1
    return b.count <= limit
  }
}

// Prefix used for burst detection: country code plus the next few digits.
export const prefixOf = (e164, digits = 6) => e164.slice(0, 1 + digits)

export function maySend({ e164, ip }, hit) {
  if (!hit('num:' + e164, 3, 60 * 60_000)) return { ok: false, reason: 'number_limit' }
  if (!hit('ip:' + ip, 5, 60 * 60_000)) return { ok: false, reason: 'ip_limit' }
  if (!hit('pfx:' + prefixOf(e164), 20, 10 * 60_000)) return { ok: false, reason: 'prefix_burst' }
  return { ok: true }
}

A few results from the test run:

(202) 555-0143, US      -> reserved_range
07700 900123, GB        -> reserved_range
020 7946 0958, GB       -> reserved_range
(02) 5550 1234, AU      -> reserved_range
0491 570 156, AU        -> reserved_range
0491 570 160, AU        -> ok, +61491570160
12345, GB               -> impossible
+371 2000 0000          -> country_not_served
07400 123456, GB        -> ok, +447400123456
4th code to one number in an hour   -> number_limit
6th code from one IP in an hour     -> ip_limit
21st code to one prefix in 10 min   -> prefix_burst

The PHP version

Tested with PHP 8.4.26 and giggsey/libphonenumber-for-php 9.0.41. Add the ACMA mobile list from the Node version if you serve Australia.

<?php

use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use libphonenumber\ValidationResult;

final class SmsGuard
{
    private const ALLOWED = ['GB', 'US', 'CA', 'AU', 'IE'];

    private const RESERVED = [
        '/^\+1\d{3}55501\d\d$/',                        // NANP 555-0100 to 555-0199
        '/^\+447700900\d{3}$/',                         // Ofcom drama mobile
        '/^\+44(11[3-8]|121|131|141|151|161)4960\d{3}$/',
        '/^\+442079460\d{3}$/', '/^\+441914980\d{3}$/',
        '/^\+442896496\d{3}$/', '/^\+442920180\d{3}$/', '/^\+441632960\d{3}$/',
        '/^\+61[2378](5550|7010)\d{4}$/',              // ACMA geographic
    ];

    /** @return array{ok: bool, reason?: string, e164?: string} */
    public static function check(string $input, string $country): array
    {
        $util = PhoneNumberUtil::getInstance();
        try {
            $number = $util->parse($input, $country);
        } catch (NumberParseException) {
            return ['ok' => false, 'reason' => 'impossible'];
        }
        if ($util->isPossibleNumberWithReason($number) !== ValidationResult::IS_POSSIBLE) {
            return ['ok' => false, 'reason' => 'impossible'];
        }
        // An invalid number has no region, so fall back to the country calling code.
        $region = $util->getRegionCodeForNumber($number)
            ?? $util->getRegionCodeForCountryCode($number->getCountryCode());
        if (! in_array($region, self::ALLOWED, true)) {
            return ['ok' => false, 'reason' => 'country_not_served'];
        }
        $e164 = $util->format($number, PhoneNumberFormat::E164);
        foreach (self::RESERVED as $pattern) {
            if (preg_match($pattern, $e164)) {
                return ['ok' => false, 'reason' => 'reserved_range', 'e164' => $e164];
            }
        }
        if (! $util->isValidNumber($number)) {
            return ['ok' => false, 'reason' => 'not_in_numbering_plan', 'e164' => $e164];
        }

        return ['ok' => true, 'e164' => $e164];
    }

    public static function prefix(string $e164, int $digits = 6): string
    {
        return substr($e164, 0, 1 + $digits);
    }
}

One thing tripped me up while testing: for a number that is not valid in the plan, such as the Ofcom drama mobile range, getRegionCodeForNumber() returns null. If you allowlist on that alone, every reserved number looks like a foreign one. Falling back to the region for the country calling code fixes it. I also used isPossibleNumberWithReason() rather than isPossibleNumber(), because the latter accepts numbers that are only possible when dialled locally, such as 12345.

In Laravel, the three counters map neatly onto RateLimiter::attempt() with keys like sms:num:+447400123456, sms:ip:203.0.113.7 and sms:pfx:+447400. I have not included that code because it is a thin wrapper; the decisions are the part worth testing.

Step five: cap spend at the provider

Your code can have bugs. The provider's spend limit is the backstop that does not.

  • AWS End User Messaging SMS has an account limit and an optional enforced monthly limit; when you hit it, sending stops until you raise it (AWS). Amazon SNS has an account spend limit too, and its docs note sending stops within minutes of the limit being passed, so a little overspend is possible (AWS SNS).
  • Twilio has a feature for its one-time code service that looks at your historical traffic and blocks destination prefixes when it sees pumping patterns. Its docs say it covers SMS only, not voice, and lists conditions on which code and protection settings it supports (Twilio). It sits alongside the per country geo permissions mentioned above.

Set the cap low enough that a bad night is annoying rather than serious, and set an alert well below it.

Watch the ratio

The best single number to chart is codes sent against codes entered, per country and per prefix. Normal traffic converts. Pumped traffic does not. If a prefix sends fifty codes and none come back, you do not need anything more sophisticated to know what happened.

Disclosure

I built Orisift, which flags reserved and fictional ranges at signup, among other checks on the phone, email, IP and domain. There is a free checker for the reserved ranges here: reserved phone number checker. Everything above works without it.