Diagnostic Twilio
a Verify Service with zero rate limits configured
Verify looks protected. There is a limit on how often one phone number can be sent a code, and you have seen 60212 in the logs, so something is clearly throttling something. Then a scripted signup endpoint sends ten thousand verifications to ten thousand different numbers from a single host, hits no limit at all, and the invoice explains why: the protection you were relying on is keyed on the destination, and the attacker changes the destination every time.
Read GET https://verify.twilio.com/v2/Services/{ServiceSid}/RateLimits and flag an empty rate_limits[]. Then, for each RK... that exists, read GET https://verify.twilio.com/v2/Services/{ServiceSid}/RateLimits/{RateLimitSid}/Buckets and flag an empty buckets[] — a Rate Limit with no buckets enforces nothing.
Verify's built-in platform protections are per phone number. Service Rate Limits are keyed on your identifier — IP, user ID, number prefix — and they are opt-in, so an attacker rotating destinations from one client is unthrottled until you create one.
The problem in plain words
The dangerous part of this failure is that the account is not unprotected, it is protected against the wrong thing. Twilio does throttle repeated verifications to the same phone number, and that limit is real: it stops the retry loop, the impatient user hammering "resend", the double-firing form. What it does not stop, and was never meant to stop, is one client walking through a list of numbers it has never used before.
That is the exact shape of both attacks people run against a Verify endpoint. SMS pumping needs many distinct destinations, one message each. Enumeration and account probing need many distinct destinations, one message each. Neither one triggers a per-destination limit, because neither one repeats a destination.
And because the limits are opt-in, the configuration that leaves you exposed is the configuration you get by default. Nothing in the Console is red. The Service works. Verifications start, codes arrive, users log in. The finding is the absence of a resource, which is the one thing no dashboard shows you.
Why it happens
Platform protection and Service Rate Limits solve different problems. The built-in guard is per phone number and always on. Service Rate Limits are per key of your choosing and off until you create them. Reading the first as coverage for the second is the mistake this note exists to catch.
A Rate Limit with no buckets is inert. The RateLimit resource is a named key; the Bucket underneath is the actual max per interval. Creating the key and stopping there is a common half-finished state, and it reads as configured in a listing: the name is there, the SID is there, and nothing is enforced.
Passing the key on send is the other half. A limit keyed on end_user_ip only applies when the verification start includes RateLimits={"end_user_ip": "..."}. The resource can be perfect and every request can bypass it because the field was never sent.
Generous buckets look like limits and are not. A bucket of a thousand starts per minute per IP is a resource, a SID and a number, and it will never stop anything a script does. The audit has to read the arithmetic, not the existence.
The cost of missing this is uncapped. Every verification is a billed message at international rates. There is no natural ceiling on the number of destinations in the world, so an unthrottled start endpoint is a wire from your card to whoever finds it first.
The fix, as a flow
The script joins two responses per service, because a Rate Limit is only a named key: the bucket underneath it is the max per interval, and a limit without one enforces nothing at all.
How to fix it
List the Services, then the Rate Limits on each
GET https://verify.twilio.com/v2/Services for the inventory, then GET https://verify.twilio.com/v2/Services/{ServiceSid}/RateLimits per Service. An empty rate_limits[] is the headline finding: nothing beyond the per-destination platform guard applies to that Service.
Read the buckets under every limit
GET https://verify.twilio.com/v2/Services/{ServiceSid}/RateLimits/{RateLimitSid}/Buckets. Each bucket carries max and interval. No buckets means the limit exists in name only. This is the check that separates a Service that is configured from one that merely has resources in it.
Convert every bucket to starts per minute
max / interval * 60 makes buckets comparable and makes generous ones obvious. Five per sixty seconds is five a minute. A thousand per sixty seconds is a resource that exists to be pointed at in a review. The tightest bucket across all limits is the one that actually binds.
Check that the key is being sent
The audit cannot see this from the API, and it is where most half-working setups fail: the RateLimits parameter has to be passed on every verification start for the limit to apply. Grep the code that calls Verify for the unique name the Service defines. A limit nobody keys is the same as no limit.
Create the limit and its buckets, then re-run
Create a limit with a UniqueName such as end_user_ip, then two buckets under it: a short one for bursts and a long one for the day's total. Re-run the audit and every Service should report a tightest bucket in starts per minute rather than unlimited.
How to check it worked
Re-run the script. Every Service should report a bucket, and the unlimited count should be zero.
python3 twilio_verify_rate_limit_audit.py
# 3 service(s), 0 with no effective limit
The full code
One GET for the Services, one per Service for its limits, and one per limit for its buckets — read-only throughout, so give it an API Key with read access. The classifier is pure and takes the shape the API returns: it separates a Service with no limits from one whose limit has no buckets, because those look identical in a Console listing and only the second one has somebody halfway through the job.
"""Report Verify Services with no effective rate limit on verification starts.
Verify's built-in protections are per destination phone number. Service Rate
Limits are keyed on your own identifier and are opt-in, so a script rotating
destinations from one IP is unthrottled until one exists.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_verify_rate_limit_audit")
VERIFY = "https://verify.twilio.com/v2"
# Above this, a bucket is a resource rather than a brake: no human signup flow
# needs thirty verification starts a minute from one key.
LOOSE_PER_MINUTE = 30.0
def starts_per_minute(bucket):
"""Normalise one bucket to starts per minute, or None if it is unreadable.
Buckets are written in whatever interval suited the author -- 5 per 60s, 25
per 3600s -- and cannot be compared until they are in the same unit.
"""
try:
max_ = float(bucket.get("max"))
interval = float(bucket.get("interval"))
except (TypeError, ValueError):
return None
if interval <= 0:
return None
return max_ * 60.0 / interval
def verdict(limits, loose_per_minute=LOOSE_PER_MINUTE):
"""Classify one Verify Service from its rate limits and their buckets.
`limits` is a list of {"unique_name": str, "buckets": [{"max", "interval"}]},
which is the two API responses joined. Pure, so the difference between no
limits and a limit with no buckets can be tested without a network.
Returns (state, detail).
"""
if not limits:
return ("unlimited",
"no Service Rate Limits at all. The only protection is Twilio's "
"per destination number guard, which does nothing against one "
"client rotating through numbers it has not used before.")
inert = [str(l.get("unique_name") or l.get("sid") or "?")
for l in limits if not l.get("buckets")]
live = [(l, b) for l in limits for b in (l.get("buckets") or [])]
if not live:
return ("inert",
"%d rate limit(s) with no buckets: %s. The limit resource is a "
"named key; the bucket underneath is the max per interval, so a "
"limit without one enforces nothing."
% (len(inert), ", ".join(inert)))
rated = [(starts_per_minute(b), l, b) for l, b in live]
rated = [r for r in rated if r[0] is not None]
if not rated:
return ("inert",
"buckets present but none has a readable max and interval")
rate, limit, bucket = min(rated, key=lambda r: r[0])
tightest = ("tightest bucket is %s: %s per %ss (%.1f/min)"
% (limit.get("unique_name") or limit.get("sid") or "?",
bucket.get("max"), bucket.get("interval"), rate))
if inert:
tightest += "; no buckets on " + ", ".join(inert)
if rate > loose_per_minute:
return ("loose",
"%s, above %.0f/min. That is a resource, not a brake: a script "
"will sit under it all day." % (tightest, loose_per_minute))
return ("limited", tightest)
def get(session, url, **params):
r = session.get(url, params=params, timeout=30)
if r.status_code in (401, 403):
raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
"API key belongs to that account with read access"
% r.status_code)
r.raise_for_status()
return r.json()
def page(session, url, field, **params):
"""Walk a Verify v2 list. Paging lives in meta.next_page_url."""
out = []
while url:
body = get(session, url, **params)
out.extend(body.get(field, []))
url, params = (body.get("meta") or {}).get("next_page_url"), {}
return out
def limits_with_buckets(session, service_sid):
"""Join RateLimits to their Buckets: one GET per limit, and the join is the
only way to tell a configured Service from one with an empty named key.
"""
base = "%s/Services/%s/RateLimits" % (VERIFY, service_sid)
out = []
for limit in page(session, base, "rate_limits", PageSize=50):
buckets = page(session, "%s/%s/Buckets" % (base, limit.get("sid")),
"buckets", PageSize=50)
out.append({"sid": limit.get("sid"),
"unique_name": limit.get("unique_name"),
"buckets": buckets})
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--service", action="append", default=[],
help="Verify Service SID; repeatable. Default: every service")
ap.add_argument("--loose-per-minute", type=float, default=LOOSE_PER_MINUTE,
help="starts per minute above which a bucket is not a brake")
args = ap.parse_args()
account = os.environ.get("TWILIO_ACCOUNT_SID")
key = os.environ.get("TWILIO_API_KEY")
secret = os.environ.get("TWILIO_API_SECRET")
if not (account and key and secret):
log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
"(an API Key with read access, not the auth token)")
return 2
session = requests.Session()
session.auth = (key, secret)
if args.service:
services = [{"sid": s, "friendly_name": s} for s in args.service]
else:
services = page(session, VERIFY + "/Services", "services", PageSize=50)
if not services:
log.info("no Verify services on this account")
return 0
bad = 0
for svc in services:
sid = svc.get("sid")
limits = limits_with_buckets(session, sid)
state, detail = verdict(limits, args.loose_per_minute)
line = "%-9s %s (%s) %s" % (state, svc.get("friendly_name", "?"), sid, detail)
if state == "limited":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: create %s/Services/%s/RateLimits with "
"UniqueName=end_user_ip, then a bucket Max=5 Interval=60 and "
"a second Max=25 Interval=3600", VERIFY, sid)
log.warning(" then pass RateLimits={\"end_user_ip\": \"<ip>\"} on every "
"verification start, or the limit never applies")
log.info("%d service(s), %d with no effective limit", len(services), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Verify Services with no effective rate limit on verification starts.
*
* Verify's built-in protections are per destination phone number. Service Rate
* Limits are keyed on your own identifier and are opt-in, so a script rotating
* destinations from one IP is unthrottled until one exists.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The repair is printed, never
* performed.
*/
const VERIFY = 'https://verify.twilio.com/v2';
// Above this, a bucket is a resource rather than a brake.
export const LOOSE_PER_MINUTE = 30;
/**
* Normalise one bucket to starts per minute, or null if it is unreadable.
* Buckets are written in whatever interval suited the author and cannot be
* compared until they are in the same unit.
*/
export function startsPerMinute(bucket) {
// Number(null) is 0, not NaN, so a missing max has to be rejected before the
// arithmetic rather than after it.
if (bucket?.max === null || bucket?.max === undefined) return null;
if (bucket?.interval === null || bucket?.interval === undefined) return null;
const max = Number(bucket.max);
const interval = Number(bucket.interval);
if (!Number.isFinite(max) || !Number.isFinite(interval) || interval <= 0) return null;
return (max * 60) / interval;
}
/**
* Classify one Verify Service from its rate limits and their buckets. `limits`
* is the two API responses joined. Pure, so the difference between no limits and
* a limit with no buckets can be tested without a network.
* Returns [state, detail].
*/
export function verdict(limits, loosePerMinute = LOOSE_PER_MINUTE) {
if (!limits || limits.length === 0) {
return ['unlimited',
'no Service Rate Limits at all. The only protection is Twilio\'s per ' +
'destination number guard, which does nothing against one client ' +
'rotating through numbers it has not used before.'];
}
const inert = limits.filter((l) => !(l.buckets ?? []).length)
.map((l) => l.unique_name ?? l.sid ?? '?');
const live = limits.flatMap((l) => (l.buckets ?? []).map((b) => [l, b]));
if (live.length === 0) {
return ['inert',
`${inert.length} rate limit(s) with no buckets: ${inert.join(', ')}. The ` +
'limit resource is a named key; the bucket underneath is the max per ' +
'interval, so a limit without one enforces nothing.'];
}
const rated = live.map(([l, b]) => [startsPerMinute(b), l, b])
.filter(([r]) => r !== null);
if (rated.length === 0) {
return ['inert', 'buckets present but none has a readable max and interval'];
}
const [rate, limit, bucket] = rated.reduce((a, b) => (b[0] < a[0] ? b : a));
let tightest = `tightest bucket is ${limit.unique_name ?? limit.sid ?? '?'}: ` +
`${bucket.max} per ${bucket.interval}s (${rate.toFixed(1)}/min)`;
if (inert.length) tightest += `; no buckets on ${inert.join(', ')}`;
if (rate > loosePerMinute) {
return ['loose',
`${tightest}, above ${loosePerMinute}/min. That is a resource, not a ` +
'brake: a script will sit under it all day.'];
}
return ['limited', tightest];
}
function authHeader(key, secret) {
return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}
async function get(auth, url, params = {}) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const res = await fetch(u, { headers: { Authorization: auth } });
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
'that the API key belongs to that account with read access');
}
if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
return res.json();
}
/** Walk a Verify v2 list. Paging lives in meta.next_page_url. */
async function page(auth, url, field, params = {}) {
const out = [];
let next = url;
let p = params;
while (next) {
const body = await get(auth, next, p);
out.push(...(body[field] ?? []));
next = body.meta?.next_page_url ?? null;
p = {};
}
return out;
}
export async function limitsWithBuckets(auth, serviceSid) {
const base = `${VERIFY}/Services/${serviceSid}/RateLimits`;
const out = [];
for (const limit of await page(auth, base, 'rate_limits', { PageSize: 50 })) {
const buckets = await page(auth, `${base}/${limit.sid}/Buckets`, 'buckets',
{ PageSize: 50 });
out.push({ sid: limit.sid, unique_name: limit.unique_name, buckets });
}
return out;
}
async function main() {
const account = process.env.TWILIO_ACCOUNT_SID;
const key = process.env.TWILIO_API_KEY;
const secret = process.env.TWILIO_API_SECRET;
if (!account || !key || !secret) {
console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
'(an API Key with read access, not the auth token)');
process.exitCode = 2;
return;
}
const auth = authHeader(key, secret);
const services = await page(auth, `${VERIFY}/Services`, 'services', { PageSize: 50 });
if (services.length === 0) {
console.log('no Verify services on this account');
return;
}
let bad = 0;
for (const svc of services) {
const limits = await limitsWithBuckets(auth, svc.sid);
const [state, detail] = verdict(limits);
const line = `${state.padEnd(9)} ${svc.friendly_name ?? '?'} (${svc.sid}) ${detail}`;
if (state === 'limited') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: create ${VERIFY}/Services/${svc.sid}/RateLimits with ` +
'UniqueName=end_user_ip, then a bucket Max=5 Interval=60 and a ' +
'second Max=25 Interval=3600');
console.warn(' then pass RateLimits={"end_user_ip": "<ip>"} on every ' +
'verification start, or the limit never applies');
}
console.log(`${services.length} service(s), ${bad} with no effective limit`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Three states have to stay distinct, because in a Console listing they look the same: no limits, a limit whose bucket list is empty, and a bucket so generous that nothing will ever hit it. The last two tests cover the case that decides whether the report is trustworthy — a Service with one real bucket and one abandoned key is limited, and the abandoned key still gets named.
from twilio_verify_rate_limit_audit import starts_per_minute, verdict
def test_no_rate_limits_at_all_is_the_headline_finding():
state, detail = verdict([])
assert state == "unlimited"
assert "per destination number guard" in detail
def test_limit_with_no_buckets_enforces_nothing():
state, detail = verdict([{"unique_name": "end_user_ip", "buckets": []}])
assert state == "inert"
assert "end_user_ip" in detail
def test_five_per_minute_is_a_real_brake():
state, detail = verdict([{"unique_name": "end_user_ip",
"buckets": [{"max": 5, "interval": 60}]}])
assert state == "limited"
assert "5.0/min" in detail
def test_a_thousand_a_minute_is_a_resource_not_a_brake():
state, detail = verdict([{"unique_name": "end_user_ip",
"buckets": [{"max": 1000, "interval": 60}]}])
assert state == "loose"
assert "all day" in detail
def test_tightest_bucket_across_limits_is_the_one_that_binds():
state, detail = verdict([
{"unique_name": "user_id", "buckets": [{"max": 600, "interval": 60}]},
{"unique_name": "end_user_ip", "buckets": [{"max": 5, "interval": 60}]},
])
assert state == "limited"
assert "end_user_ip" in detail
def test_an_abandoned_key_is_named_even_when_another_limit_works():
state, detail = verdict([
{"unique_name": "end_user_ip", "buckets": [{"max": 5, "interval": 60}]},
{"unique_name": "prefix", "buckets": []},
])
assert state == "limited"
assert "no buckets on prefix" in detail
def test_buckets_are_normalised_to_starts_per_minute():
assert starts_per_minute({"max": 25, "interval": 3600}) == 25 * 60 / 3600
assert starts_per_minute({"max": 5, "interval": 0}) is None
assert starts_per_minute({"max": None, "interval": 60}) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startsPerMinute, verdict } from './twilio-verify-rate-limit-audit.mjs';
test('no rate limits at all is the headline finding', () => {
const [state, detail] = verdict([]);
assert.equal(state, 'unlimited');
assert.match(detail, /per destination number guard/);
});
test('limit with no buckets enforces nothing', () => {
const [state, detail] = verdict([{ unique_name: 'end_user_ip', buckets: [] }]);
assert.equal(state, 'inert');
assert.match(detail, /end_user_ip/);
});
test('five per minute is a real brake', () => {
const [state, detail] = verdict([
{ unique_name: 'end_user_ip', buckets: [{ max: 5, interval: 60 }] }]);
assert.equal(state, 'limited');
assert.match(detail, /5\.0\/min/);
});
test('a thousand a minute is a resource not a brake', () => {
const [state, detail] = verdict([
{ unique_name: 'end_user_ip', buckets: [{ max: 1000, interval: 60 }] }]);
assert.equal(state, 'loose');
assert.match(detail, /all day/);
});
test('tightest bucket across limits is the one that binds', () => {
const [state, detail] = verdict([
{ unique_name: 'user_id', buckets: [{ max: 600, interval: 60 }] },
{ unique_name: 'end_user_ip', buckets: [{ max: 5, interval: 60 }] },
]);
assert.equal(state, 'limited');
assert.match(detail, /end_user_ip/);
});
test('an abandoned key is named even when another limit works', () => {
const [state, detail] = verdict([
{ unique_name: 'end_user_ip', buckets: [{ max: 5, interval: 60 }] },
{ unique_name: 'prefix', buckets: [] },
]);
assert.equal(state, 'limited');
assert.match(detail, /no buckets on prefix/);
});
test('buckets are normalised to starts per minute', () => {
assert.equal(startsPerMinute({ max: 25, interval: 3600 }), (25 * 60) / 3600);
assert.equal(startsPerMinute({ max: 5, interval: 0 }), null);
assert.equal(startsPerMinute({ max: null, interval: 60 }), null);
});
FAQ
Twilio already rate limits verifications. Why do I need my own?
Because the built-in protection is keyed on the destination phone number, and both attacks that matter use each destination once. Pumping needs many numbers, one message each; enumeration needs many numbers, one message each. Neither repeats a destination, so neither meets the per-number limit.
What is the difference between a Rate Limit and a Bucket?
The Rate Limit is the named key you throttle on, such as end_user_ip or user_id. The Bucket underneath is the enforcement: max starts per interval seconds. A limit with no buckets is a name with nothing behind it, and it is the state a half-finished setup leaves behind.
Does creating the limit make it apply automatically?
No. The verification start has to pass a value for the key, as RateLimits with your unique name and the caller's IP or user id. The resource and the request are two halves of the same control, and an audit can only see one of them, which is why the note tells you to grep your own code for the unique name.
What should the buckets actually be?
Two per key works well: a short window that stops bursts, such as five per sixty seconds, and a longer one that caps the day, such as twenty-five per hour. The short one blocks a script; the long one blocks a script that has learned to wait.
Why flag a bucket that exists but is generous?
Because it passes every review that checks for existence. A thousand starts per minute per IP is a number no human flow will ever reach, and a script will sit comfortably underneath it forever. Normalising every bucket to starts per minute is what makes that visible in one column.
Related field notes
- Verify conversion collapsing in one country
- Fraud Guard blocking a prefix your users live on
- SMS Pumping Protection blocking legitimate OTPs
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Verify Service Rate Limits — Twilio Docs
- Verify Rate Limit Buckets — Twilio Docs
- Preventing toll fraud and SMS pumping — Twilio Docs
- API keys — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.