Diagnostic Twilio
a Verify Service issuing four-digit codes to production
Nothing is failing. Codes arrive, users type them in, verifications approve, and conversion rate is flat and healthy. The only thing to notice is one integer in the Service resource, set during the first week of the integration when a four-digit code was faster to read off a phone during QA — and that has been the length of every code you have sent since.
Read GET https://verify.twilio.com/v2/Services/{ServiceSid} and flag any Service whose code_length is below 6. Read custom_code_enabled in the same response: true in production means your own application supplies the code and Twilio's randomness is not involved at all.
Four digits is 10000 possible codes. Each verification allows five checks before 60202, and the budget is scoped to the verification, so starting a fresh one resets it. That is roughly 1000 starts for even odds against a single code. Six digits multiplies both numbers by a hundred.
The problem in plain words
This is not a bug and it never becomes one. There is no error, no failed request, no degraded metric, nothing that fires. The Service does exactly what it was configured to do on the day somebody typed a 4 into a form, and it will keep doing it forever, because the only symptom is a number that has to be reasoned about rather than observed.
The arithmetic only becomes dangerous in combination with an endpoint that will start verifications on demand, which is the ordinary shape of a signup or login form. Five guesses per verification is a real ceiling; five guesses per verification, and anyone can create verifications, is not a ceiling at all. That second half is a Verify Service with zero rate limits, and the two findings are worth reading together — each one is survivable and the pair is not.
When it does get exercised, the evidence looks like ordinary user friction. A grinding attack produces a stream of verifications that each end in max_attempts_reached, which is indistinguishable from people mistyping codes unless somebody is already counting the rate. Twilio's own protections notice repetition against one destination; an attacker working against one account's phone number is not repeating anything else.
Why it happens
The length was a product decision, made once, under time pressure. Shorter codes are easier to read aloud, easier to type on a small keyboard, and produce fewer support calls about typos. Every one of those arguments is true, none of them is wrong, and all of them were made before anyone in the room had run the keyspace numbers. Nothing about the decision decays, so nothing ever prompts a second look.
The five-check budget is scoped to the verification, not to the phone number. That is the mechanism that makes a short code cheap to attack. Burning all five checks kills one verification and nothing else; the next POST to start a verification hands over five more. The defence is per-object and the attack is per-request, so the attack simply buys more objects.
custom_code_enabled turns the length field into decoration. With it on, your application supplies the code and Twilio delivers whatever it is given. code_length still sits in the response looking authoritative while describing nothing. It gets switched on for a test harness that needs deterministic codes, and switching it back off is a step in nobody's checklist.
The ten-minute lifetime caps rate, not total attempts. A code expiring limits how many guesses fit inside one window, but the attacker is not confined to one window. They start another verification. The TTL is the reason an attack takes hours instead of minutes; it is not the reason it fails.
Nothing in your own logs records the exposure. A short code produces no distinct error, no alert, and no line in the Debugger. The field is readable in one GET and invisible in every dashboard, which is why an audit that reads it deliberately is the only thing that finds it.
The fix, as a flow
The script prints the keyspace and the number of starts an attacker needs rather than the length itself, because 4 and 6 read as neighbouring integers and are a hundredfold apart in work.
How to fix it
List the Services and read the two fields that matter
GET https://verify.twilio.com/v2/Services for the inventory, then read code_length and custom_code_enabled off each Service in the same response. Accounts collect Services — one per brand, one per environment, one from a proof of concept — and the short one is rarely the one you were looking at.
Do the arithmetic instead of eyeballing the integer
4 and 6 look like neighbouring numbers and are not. Ten thousand codes against a million; a thousand verification starts for even odds against a hundred thousand. Printing the keyspace and the start count next to the length is what turns the field from a preference into a finding somebody will act on.
Treat custom_code_enabled as outranking the length
If custom_code_enabled is true, stop reading code_length: it no longer describes what gets sent. Whatever your code generates is the code, including the sequential integers a test fixture was handing out when somebody enabled the flag.
Find out whether the length is load-bearing elsewhere
Raising it breaks a fixed-width four-box input, an SMS template that says "your 4 digit code", a voice prompt that reads digits in pairs, and any test fixture that hard-codes a value. This is the step that actually takes the time, and skipping it is how a code-length change becomes an outage on the login screen.
Raise it, then pair it with a rate limit
POST https://verify.twilio.com/v2/Services/{ServiceSid} with CodeLength=6 and CustomCodeEnabled=false. Existing verifications keep the length they were created with, so the change is not retroactive and does not invalidate codes already in flight. Then add a Service Rate Limit, because the check budget resets on every start and the length alone is only half the control.
How to check it worked
Re-run the script. Every Service should report ok, and the count below the bar should be zero.
python3 twilio_verify_code_length_audit.py
# 3 service(s), 0 issuing codes below 6 digits
The full code
One paginated GET over the Verify Services and nothing else — give it an API Key with read access. The classifier is pure and does the arithmetic in the output, because a report that says code_length is 4 gets filed and a report that says 10000 codes, about 1000 starts for even odds gets fixed.
"""Report Verify Services issuing codes short enough to grind through.
The five-check budget that protects a code is scoped to the verification, so an
attacker who can start verifications resets it at will. That makes the keyspace,
not the check limit, the number that decides how much work an attack is.
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,
because this script holds a credential to an account that can spend money.
"""
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_code_length_audit")
VERIFY = "https://verify.twilio.com/v2"
# Fixed by the platform: the fifth failed check on a verification returns 60202
# and that verification is dead for the rest of its lifetime. Starting a new one
# hands the caller five more, which is the whole reason length matters.
CHECKS_PER_VERIFICATION = 5
# Twilio accepts 4 through 10.
MIN_LENGTH_ALLOWED = 4
MAX_LENGTH_ALLOWED = 10
MIN_SAFE_LENGTH = 6
def keyspace(code_length):
"""Number of codes a length can produce, or None if the value is unusable.
Anything outside the range Twilio issues is not a length to reason about,
and reporting it as safe would be worse than reporting it as unknown.
"""
try:
n = int(code_length)
except (TypeError, ValueError):
return None
if n < MIN_LENGTH_ALLOWED or n > MAX_LENGTH_ALLOWED:
return None
return 10 ** n
def starts_for_even_odds(space, checks=CHECKS_PER_VERIFICATION):
"""Fresh verifications needed for a 50/50 chance of hitting one code.
Half the space on average, five guesses per verification, because the check
budget belongs to the verification and starting another one resets it.
"""
if not space or checks <= 0:
return None
return int(round(space / (2.0 * checks)))
def verdict(service, min_length=MIN_SAFE_LENGTH):
"""Classify one Verify Service by how guessable the codes it issues are.
Pure, so the arithmetic can be tested without a network. Returns
(state, detail).
"""
length = service.get("code_length")
space = keyspace(length)
if service.get("custom_code_enabled"):
return ("custom-code",
"custom_code_enabled is true: the codes come from your own "
"application, so code_length (%s) describes nothing that is "
"actually sent and Twilio generates none of it." % (length,))
if space is None:
return ("unreadable",
"code_length is %r, which is not a length Twilio issues (%d to "
"%d). Report it as unknown rather than as safe."
% (length, MIN_LENGTH_ALLOWED, MAX_LENGTH_ALLOWED))
n = int(length)
detail = ("%d digits: %d codes, %d checks per verification, about %d fresh "
"starts for even odds against one code."
% (n, space, CHECKS_PER_VERIFICATION, starts_for_even_odds(space)))
if n < min_length - 1:
return ("short", detail + " Nothing caps the number of starts but you.")
if n < min_length:
return ("thin", detail + " An afternoon of scripted starts, not a week.")
return ("ok", detail)
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 main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--service", action="append", default=[],
help="Verify Service SID; repeatable. Default: every service")
ap.add_argument("--min-length", type=int, default=MIN_SAFE_LENGTH,
help="shortest code length to accept without comment")
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 = [get(session, "%s/Services/%s" % (VERIFY, 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", "?")
state, detail = verdict(svc, args.min_length)
line = "%-11s %s (%s) %s" % (state, svc.get("friendly_name", "?"), sid, detail)
if state == "ok":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: POST %s/Services/%s with CodeLength=%d and "
"CustomCodeEnabled=false", VERIFY, sid, max(args.min_length, 6))
log.warning(" then add a Service Rate Limit: the check budget resets on "
"every new verification, so length alone is half a control")
log.info("%d service(s), %d issuing codes below %d digits",
len(services), bad, args.min_length)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Verify Services issuing codes short enough to grind through.
*
* The five-check budget that protects a code is scoped to the verification, so
* an attacker who can start verifications resets it at will. That makes the
* keyspace, not the check limit, the number that decides how much work an
* attack is.
*
* 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';
// Fixed by the platform: the fifth failed check returns 60202 and that
// verification is dead. Starting a new one hands the caller five more.
export const CHECKS_PER_VERIFICATION = 5;
// Twilio accepts 4 through 10.
const MIN_LENGTH_ALLOWED = 4;
const MAX_LENGTH_ALLOWED = 10;
export const MIN_SAFE_LENGTH = 6;
/**
* Number of codes a length can produce, or null if the value is unusable.
* Anything outside the range Twilio issues is reported as unknown, never safe.
*/
export function keyspace(codeLength) {
// Number(null) is 0 rather than NaN, so a missing field has to be rejected
// before the arithmetic instead of after it.
if (codeLength === null || codeLength === undefined || codeLength === '') return null;
const n = Number(codeLength);
if (!Number.isInteger(n)) return null;
if (n < MIN_LENGTH_ALLOWED || n > MAX_LENGTH_ALLOWED) return null;
return 10 ** n;
}
/**
* Fresh verifications needed for a 50/50 chance of hitting one code: half the
* space on average, five guesses per verification.
*/
export function startsForEvenOdds(space, checks = CHECKS_PER_VERIFICATION) {
if (!space || checks <= 0) return null;
return Math.round(space / (2 * checks));
}
/**
* Classify one Verify Service by how guessable the codes it issues are. Pure,
* so the arithmetic can be tested without a network. Returns [state, detail].
*/
export function verdict(service, minLength = MIN_SAFE_LENGTH) {
const length = service.code_length;
const space = keyspace(length);
if (service.custom_code_enabled) {
return ['custom-code',
`custom_code_enabled is true: the codes come from your own application, ` +
`so code_length (${length}) describes nothing that is actually sent and ` +
'Twilio generates none of it.'];
}
if (space === null) {
return ['unreadable',
`code_length is ${JSON.stringify(length)}, which is not a length Twilio ` +
`issues (${MIN_LENGTH_ALLOWED} to ${MAX_LENGTH_ALLOWED}). Report it as ` +
'unknown rather than as safe.'];
}
const n = Number(length);
const detail = `${n} digits: ${space} codes, ${CHECKS_PER_VERIFICATION} ` +
`checks per verification, about ${startsForEvenOdds(space)} fresh starts ` +
'for even odds against one code.';
if (n < minLength - 1) {
return ['short', `${detail} Nothing caps the number of starts but you.`];
}
if (n < minLength) {
return ['thin', `${detail} An afternoon of scripted starts, not a week.`];
}
return ['ok', detail];
}
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;
}
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 [state, detail] = verdict(svc);
const line = `${state.padEnd(11)} ${svc.friendly_name ?? '?'} (${svc.sid}) ${detail}`;
if (state === 'ok') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: POST ${VERIFY}/Services/${svc.sid} with CodeLength=6 ` +
'and CustomCodeEnabled=false');
console.warn(' then add a Service Rate Limit: the check budget resets on ' +
'every new verification, so length alone is half a control');
}
console.log(`${services.length} service(s), ${bad} issuing codes below ` +
`${MIN_SAFE_LENGTH} digits`);
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
The cases worth pinning are the boundary and the two states that a naive length check gets wrong: five digits, which is below the bar without being the headline; a Service with custom_code_enabled, where the length field is honest-looking and meaningless; and a length outside the range Twilio issues, which has to come back as unknown rather than as passing.
from twilio_verify_code_length_audit import (keyspace, starts_for_even_odds,
verdict)
def test_four_digits_is_ten_thousand_codes():
state, detail = verdict({"code_length": 4})
assert state == "short"
assert "10000 codes" in detail
assert "1000 fresh starts" in detail
def test_five_digits_is_below_the_bar_without_being_the_headline():
state, detail = verdict({"code_length": 5})
assert state == "thin"
assert "100000 codes" in detail
def test_six_digits_passes():
state, detail = verdict({"code_length": 6})
assert state == "ok"
assert "1000000 codes" in detail
def test_custom_code_outranks_a_perfectly_good_length():
# The field still reads 6. It describes nothing that gets sent.
state, detail = verdict({"code_length": 6, "custom_code_enabled": True})
assert state == "custom-code"
assert "your own application" in detail
def test_a_length_twilio_cannot_issue_is_unknown_not_safe():
assert verdict({"code_length": 12})[0] == "unreadable"
assert verdict({})[0] == "unreadable"
assert verdict({"code_length": "six"})[0] == "unreadable"
def test_even_odds_spends_five_guesses_per_start():
assert starts_for_even_odds(10000) == 1000
assert starts_for_even_odds(1000000) == 100000
assert starts_for_even_odds(None) is None
def test_keyspace_covers_the_range_and_rejects_the_rest():
assert keyspace(4) == 10000
assert keyspace(10) == 10 ** 10
assert keyspace(3) is None
assert keyspace(None) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { keyspace, startsForEvenOdds, verdict } from './twilio-verify-code-length-audit.mjs';
test('four digits is ten thousand codes', () => {
const [state, detail] = verdict({ code_length: 4 });
assert.equal(state, 'short');
assert.match(detail, /10000 codes/);
assert.match(detail, /1000 fresh starts/);
});
test('five digits is below the bar without being the headline', () => {
const [state, detail] = verdict({ code_length: 5 });
assert.equal(state, 'thin');
assert.match(detail, /100000 codes/);
});
test('six digits passes', () => {
const [state, detail] = verdict({ code_length: 6 });
assert.equal(state, 'ok');
assert.match(detail, /1000000 codes/);
});
test('custom code outranks a perfectly good length', () => {
const [state, detail] = verdict({ code_length: 6, custom_code_enabled: true });
assert.equal(state, 'custom-code');
assert.match(detail, /your own application/);
});
test('a length twilio cannot issue is unknown not safe', () => {
assert.equal(verdict({ code_length: 12 })[0], 'unreadable');
assert.equal(verdict({})[0], 'unreadable');
assert.equal(verdict({ code_length: 'six' })[0], 'unreadable');
});
test('even odds spends five guesses per start', () => {
assert.equal(startsForEvenOdds(10000), 1000);
assert.equal(startsForEvenOdds(1000000), 100000);
assert.equal(startsForEvenOdds(null), null);
});
test('keyspace covers the range and rejects the rest', () => {
assert.equal(keyspace(4), 10000);
assert.equal(keyspace(10), 10 ** 10);
assert.equal(keyspace(3), null);
assert.equal(keyspace(null), null);
});
FAQ
Is four digits actually exploitable, or is this theatre?
It is exploitable exactly when your start endpoint is. Five checks per verification is a real ceiling if the attacker gets one verification. It is not a ceiling at all if they can create ten thousand. That is why the fix is two changes, not one: raise the length and add a Service Rate Limit keyed on something you control.
Does raising code_length invalidate codes already in flight?
No. A verification carries the length it was created with, so codes already sent keep working until they expire. The change applies to verifications started after it. That makes this one of the few Verify settings you can move during business hours.
Why does custom_code_enabled get its own state?
Because it makes the length field a lie rather than a low number, and the two need different fixes. With custom codes on, the report has to send you to your own generator: whatever it produces is the code, including the sequential integers a test fixture was handing out when somebody switched the flag on.
Six digits or more?
Six is the point where the keyspace stops being the weak link and your rate limits become the binding control. Longer codes buy less than the next limit does, and they cost you in typos and support volume. Spend the effort on the start endpoint instead.
Will Twilio's own protections not stop a brute force?
They stop repetition against one destination number, which is the pattern of an impatient user or a retry loop. An attacker grinding one victim's code is not repeating a destination faster than a real person would; they are starting ordinary-looking verifications for a number that is genuinely theirs to target.
Related field notes
- A Verify Service with zero rate limits configured
- A verification that burned all five checks
- OTP codes sent without the do-not-share warning
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 resource — Twilio Docs
- Error 60202: max verification check attempts reached — Twilio Docs
- Verification resource — 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.