Diagnostic Twilio
OTP codes sent without the do-not-share warning line
Somebody phoned your customer, said they were from your support team, told them there was a suspicious login and that a code was on its way to confirm it was really them. The code arrived, on time, from your number, saying nothing except what the code was. The customer read it out. Nothing in that message gave them a reason not to.
Read GET https://verify.twilio.com/v2/Services/{ServiceSid} and flag do_not_share_warning_enabled == false. That flag appends a security warning to the SMS verification body, and it is off by default, so every Service created without it sends codes with no caution line.
Then read default_template_sid in the same response. The flag appends to Twilio's default body; a custom default template carries whatever text it was approved with, and the warning may not be in it. Cross-reference GET https://verify.twilio.com/v2/Templates. For voice, dtmf_input_required == false means a voicemail box that answers the call gets read the code.
The problem in plain words
The attack this defends against does not touch your infrastructure at all. Nothing is compromised, no credential leaks, no request is malformed. Somebody talks a customer into reading out a number, and the number works, because it is a valid code delivered to the right handset at the right moment. Every log line for that session says the verification succeeded, which it did.
The single line of text is a small defence and it is a real one, because it arrives at the exact moment of the decision. The caller is on the phone saying they are from your support team; the message in the customer's hand says nobody from your support team will ever ask for this. That contradiction, delivered in the same second, is most of what stops the call.
It is missing for the most ordinary reason there is: it defaults to off, and turning it on was never a task on anybody's list. The Service was created through the API during the integration, with the fields the quickstart used, and the fields the quickstart did not mention have been at their defaults ever since.
Why it happens
The flag is off by default, so nobody chose the current state. A Service created through the API gets whatever the create call did not set, and no quickstart sets this. There is no migration, no deprecation notice, and no console warning; the setting simply stays where it was born, which for most accounts is off.
The warning is appended to Twilio's default body, not to yours. Set default_template_sid to a custom template and the body is now whatever that template says. The flag can be true in the API while the message going out has no warning in it, which is the one state a boolean check reports as safe and is not.
Nobody reads their own OTP messages. The team receives the code in a test, types it in, and confirms the flow works. Reading the sentence around the digits is not part of confirming the flow works, so a body with no caution line passes every test anyone runs against it.
The voice channel has its own version of the same gap. dtmf_input_required makes the call wait for a keypress before reading the code. With it off, a voicemail box answers, hears the code, and stores it as an audio recording in a mailbox protected by a four-digit PIN. It is off by default too.
Templates are a per-account resource and services are not. The template list lives at the account level while the reference lives on each Service, so the two have to be joined to say anything. A Service pointing at a template your key cannot see is a real state, and the honest answer there is unresolved rather than either verdict.
The fix, as a flow
The script joins the Services to the account's Templates, because the flag appends to Twilio's default body: set a custom default template and a true flag stops meaning the warning went out.
How to fix it
Read the flag on every Service
GET https://verify.twilio.com/v2/Services and read do_not_share_warning_enabled. False is the headline finding and it is one boolean. Do it across every Service, because the one created most recently is usually the one somebody configured properly and the old one is still carrying production.
Join the default template before trusting a true
GET https://verify.twilio.com/v2/Templates once, keyed by sid. If a Service sets default_template_sid, the flag is not the whole story: the body is the template's, and the warning line is in it or it is not. Report that as needing a human read rather than as passing.
Handle a template you cannot resolve as unknown
A default_template_sid that is not in the list the key can read is not evidence of anything. Give it its own state. Reporting it as covered hides a real gap; reporting it as broken sends somebody to look at a template that is probably fine.
Check the voice channel separately, and only if you use it
dtmf_input_required matters when you send Channel=call. One paginated GET https://verify.twilio.com/v2/Attempts?VerifyServiceSid={VA...}&DateCreatedAfter={ISO8601} tells you whether any attempt used the call channel, which is the difference between a finding and a footnote.
Turn both on, then read the message on a real handset
POST https://verify.twilio.com/v2/Services/{ServiceSid} with DoNotShareWarningEnabled=true and DtmfInputRequired=true. If a custom template is in play, resubmit it with the warning line included and wait for approval. Then send yourself one and read it, because that is the check the flag cannot do for you.
How to check it worked
Re-run the script. Every Service should report warned, with no custom template left unread.
python3 twilio_verify_warning_audit.py --check-voice
# 3 service(s), 0 sending codes without a warning
The full code
One GET for the Services, one for the account's Templates, and with --check-voice one paginated GET of the attempts per Service to find out whether the voice channel is in use at all — read-only throughout. The classifier is pure and takes the templates as a map, because the interesting rule is that a true flag plus a custom template is not a pass, and that is the sentence the whole note exists to make testable.
"""Report Verify Services sending OTP codes with no do-not-share warning.
do_not_share_warning_enabled appends a security warning to the SMS body and is
off by default. It appends to Twilio's default body, so a Service with a custom
default template can have the flag on and still send a bare code.
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
from datetime import datetime, timedelta, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_verify_warning_audit")
VERIFY = "https://verify.twilio.com/v2"
def verdict(service, templates_by_sid, voice_in_use=None):
"""Classify one Verify Service by whether its codes carry a warning.
`templates_by_sid` is the account's Templates keyed on sid. `voice_in_use` is
True, False, or None when it was not checked -- the three cases produce three
different answers about dtmf_input_required, and collapsing them into a
boolean is how an audit starts inventing findings.
Pure, so the rule that a true flag plus a custom template is not a pass can
be tested without a network. Returns (state, detail).
"""
warned = bool(service.get("do_not_share_warning_enabled"))
dtmf = bool(service.get("dtmf_input_required"))
template_sid = str(service.get("default_template_sid") or "").strip()
voice_note = ""
if not dtmf and voice_in_use is True:
voice_note = (" dtmf_input_required is false and this service sends voice "
"verifications: a voicemail box answering the call is read "
"the code and keeps it.")
elif not dtmf and voice_in_use is None:
voice_note = (" dtmf_input_required is false; if you ever send "
"Channel=call, a voicemail box can capture the code.")
if not warned:
return ("no-warning",
"do_not_share_warning_enabled is false: the SMS body is the code "
"and nothing else, with no line saying that nobody legitimate "
"will ask for it." + voice_note)
if template_sid:
template = templates_by_sid.get(template_sid)
if template is None:
return ("unresolved-template",
"the flag is true, but default_template_sid %s is not in the "
"Templates this key can read, and the body comes from the "
"template. Unknown, not covered." % template_sid + voice_note)
return ("custom-template",
"the flag is true, but the Service sends a custom default "
"template (%s, %s) and the flag appends to Twilio's default body. "
"Read the translations before calling this covered."
% (template_sid, template.get("friendly_name") or "unnamed")
+ voice_note)
if not dtmf and voice_in_use is True:
return ("voice-exposed",
"the SMS body carries the warning, but" + voice_note)
return ("warned",
"do_not_share_warning_enabled is true and the built-in default "
"template is in use." + voice_note)
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 voice_used(session, service_sid, since):
"""True when any attempt in the window used the call channel."""
for attempt in page(session, VERIFY + "/Attempts", "attempts",
VerifyServiceSid=service_sid, DateCreatedAfter=since,
PageSize=100):
if str(attempt.get("channel") or "").lower() == "call":
return True
return False
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check-voice", action="store_true",
help="one paginated GET per service to see if voice is used")
ap.add_argument("--hours", type=int, default=168,
help="window for the voice channel check")
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)
services = page(session, VERIFY + "/Services", "services", PageSize=50)
if not services:
log.info("no Verify services on this account")
return 0
templates = {t.get("sid"): t
for t in page(session, VERIFY + "/Templates", "templates",
PageSize=50)}
since = (datetime.now(timezone.utc) - timedelta(hours=args.hours)
).strftime("%Y-%m-%dT%H:%M:%SZ")
bad = 0
for svc in services:
sid = svc.get("sid")
voice = voice_used(session, sid, since) if args.check_voice else None
state, detail = verdict(svc, templates, voice)
line = "%-19s %s (%s) %s" % (state, svc.get("friendly_name", "?"),
sid, detail)
if state == "warned":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: POST %s/Services/%s with "
"DoNotShareWarningEnabled=true and DtmfInputRequired=true",
VERIFY, sid)
if state in ("custom-template", "unresolved-template"):
log.warning(" and read the template body: the flag appends to "
"Twilio's default, not to yours")
log.info("%d service(s), %d sending codes without a warning",
len(services), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Verify Services sending OTP codes with no do-not-share warning.
*
* do_not_share_warning_enabled appends a security warning to the SMS body and
* is off by default. It appends to the default body, so a Service with a custom
* default template can have the flag on and still send a bare code.
*
* 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';
/**
* Classify one Verify Service by whether its codes carry a warning.
*
* `templatesBySid` is the account Templates keyed on sid. `voiceInUse` is true,
* false, or null when it was not checked: the three cases produce three
* different answers about dtmf_input_required, and collapsing them into a
* boolean is how an audit starts inventing findings.
*
* Pure, so the rule that a true flag plus a custom template is not a pass can
* be tested without a network. Returns [state, detail].
*/
export function verdict(service, templatesBySid, voiceInUse = null) {
const warned = Boolean(service.do_not_share_warning_enabled);
const dtmf = Boolean(service.dtmf_input_required);
const templateSid = String(service.default_template_sid ?? '').trim();
let voiceNote = '';
if (!dtmf && voiceInUse === true) {
voiceNote = ' dtmf_input_required is false and this service sends voice ' +
'verifications: a voicemail box answering the call is read the code and ' +
'keeps it.';
} else if (!dtmf && voiceInUse === null) {
voiceNote = ' dtmf_input_required is false; if you ever send Channel=call, ' +
'a voicemail box can capture the code.';
}
if (!warned) {
return ['no-warning',
'do_not_share_warning_enabled is false: the SMS body is the code and ' +
'nothing else, with no line saying that nobody legitimate will ask for ' +
`it.${voiceNote}`];
}
if (templateSid) {
const template = templatesBySid.get
? templatesBySid.get(templateSid)
: templatesBySid[templateSid];
if (template === undefined || template === null) {
return ['unresolved-template',
`the flag is true, but default_template_sid ${templateSid} is not in ` +
'the Templates this key can read, and the body comes from the ' +
`template. Unknown, not covered.${voiceNote}`];
}
return ['custom-template',
'the flag is true, but the Service sends a custom default template ' +
`(${templateSid}, ${template.friendly_name || 'unnamed'}) and the flag ` +
'appends to the default body. Read the translations before calling this ' +
`covered.${voiceNote}`];
}
if (!dtmf && voiceInUse === true) {
return ['voice-exposed', `the SMS body carries the warning, but${voiceNote}`];
}
return ['warned',
'do_not_share_warning_enabled is true and the built-in default template is ' +
`in use.${voiceNote}`];
}
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;
}
/** True when any attempt in the window used the call channel. */
export async function voiceUsed(auth, serviceSid, since) {
const attempts = await page(auth, `${VERIFY}/Attempts`, 'attempts', {
VerifyServiceSid: serviceSid, DateCreatedAfter: since, PageSize: 100,
});
return attempts.some((a) => String(a.channel ?? '').toLowerCase() === 'call');
}
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 checkVoice = process.argv.includes('--check-voice');
const services = await page(auth, `${VERIFY}/Services`, 'services', { PageSize: 50 });
if (services.length === 0) {
console.log('no Verify services on this account');
return;
}
const templates = new Map();
for (const t of await page(auth, `${VERIFY}/Templates`, 'templates', { PageSize: 50 })) {
templates.set(t.sid, t);
}
const since = `${new Date(Date.now() - 168 * 3600 * 1000)
.toISOString().slice(0, 19)}Z`;
let bad = 0;
for (const svc of services) {
const voice = checkVoice ? await voiceUsed(auth, svc.sid, since) : null;
const [state, detail] = verdict(svc, templates, voice);
const line = `${state.padEnd(19)} ${svc.friendly_name ?? '?'} (${svc.sid}) ${detail}`;
if (state === 'warned') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: POST ${VERIFY}/Services/${svc.sid} with ` +
'DoNotShareWarningEnabled=true and DtmfInputRequired=true');
if (state === 'custom-template' || state === 'unresolved-template') {
console.warn(' and read the template body: the flag appends to the ' +
'built-in default, not to yours');
}
}
console.log(`${services.length} service(s), ${bad} sending codes without a warning`);
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
One boolean would make this a three-line script, and the tests are here to stop it being one. A true flag with a custom default template must not come back as a pass; a template SID the key cannot resolve must come back as unknown rather than as either verdict; and dtmf_input_required must only become a finding when the voice channel is actually in use, because the value of an audit is that its warnings are all real.
from twilio_verify_warning_audit import verdict
TEMPLATES = {"HJ0123456789": {"sid": "HJ0123456789", "friendly_name": "signup v3"}}
def test_the_flag_off_is_the_headline_finding():
state, detail = verdict({"do_not_share_warning_enabled": False,
"dtmf_input_required": True}, {})
assert state == "no-warning"
assert "nothing else" in detail
def test_a_custom_template_is_not_a_pass_even_with_the_flag_on():
state, detail = verdict({"do_not_share_warning_enabled": True,
"dtmf_input_required": True,
"default_template_sid": "HJ0123456789"}, TEMPLATES)
assert state == "custom-template"
assert "signup v3" in detail
def test_a_template_the_key_cannot_read_is_unknown_not_broken():
state, detail = verdict({"do_not_share_warning_enabled": True,
"dtmf_input_required": True,
"default_template_sid": "HJ9999999999"}, TEMPLATES)
assert state == "unresolved-template"
assert "Unknown, not covered" in detail
def test_the_default_template_with_the_flag_on_passes():
state, _ = verdict({"do_not_share_warning_enabled": True,
"dtmf_input_required": True}, TEMPLATES)
assert state == "warned"
def test_dtmf_is_only_a_finding_when_voice_is_actually_used():
exposed, detail = verdict({"do_not_share_warning_enabled": True,
"dtmf_input_required": False}, {}, voice_in_use=True)
assert exposed == "voice-exposed"
assert "voicemail box" in detail
assert verdict({"do_not_share_warning_enabled": True,
"dtmf_input_required": False}, {}, voice_in_use=False)[0] == "warned"
def test_an_unchecked_voice_channel_is_a_note_not_a_verdict():
state, detail = verdict({"do_not_share_warning_enabled": True,
"dtmf_input_required": False}, {})
assert state == "warned"
assert "if you ever send" in detail
def test_a_missing_flag_reads_as_off():
assert verdict({}, {})[0] == "no-warning"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './twilio-verify-warning-audit.mjs';
const TEMPLATES = new Map([
['HJ0123456789', { sid: 'HJ0123456789', friendly_name: 'signup v3' }],
]);
test('the flag off is the headline finding', () => {
const [state, detail] = verdict(
{ do_not_share_warning_enabled: false, dtmf_input_required: true }, new Map());
assert.equal(state, 'no-warning');
assert.match(detail, /nothing else/);
});
test('a custom template is not a pass even with the flag on', () => {
const [state, detail] = verdict({
do_not_share_warning_enabled: true,
dtmf_input_required: true,
default_template_sid: 'HJ0123456789',
}, TEMPLATES);
assert.equal(state, 'custom-template');
assert.match(detail, /signup v3/);
});
test('a template the key cannot read is unknown not broken', () => {
const [state, detail] = verdict({
do_not_share_warning_enabled: true,
dtmf_input_required: true,
default_template_sid: 'HJ9999999999',
}, TEMPLATES);
assert.equal(state, 'unresolved-template');
assert.match(detail, /Unknown, not covered/);
});
test('the default template with the flag on passes', () => {
const [state] = verdict(
{ do_not_share_warning_enabled: true, dtmf_input_required: true }, TEMPLATES);
assert.equal(state, 'warned');
});
test('dtmf is only a finding when voice is actually used', () => {
const [state, detail] = verdict(
{ do_not_share_warning_enabled: true, dtmf_input_required: false },
new Map(), true);
assert.equal(state, 'voice-exposed');
assert.match(detail, /voicemail box/);
assert.equal(verdict(
{ do_not_share_warning_enabled: true, dtmf_input_required: false },
new Map(), false)[0], 'warned');
});
test('an unchecked voice channel is a note not a verdict', () => {
const [state, detail] = verdict(
{ do_not_share_warning_enabled: true, dtmf_input_required: false }, new Map());
assert.equal(state, 'warned');
assert.match(detail, /if you ever send/);
});
test('a missing flag reads as off', () => {
assert.equal(verdict({}, new Map())[0], 'no-warning');
});
FAQ
Does one line of text really stop anything?
It stops the specific attack it is aimed at, which is a person on the phone asking for the code. The warning arrives in the same second as the request and contradicts it directly. It does nothing against a compromised handset or an intercepted message, and it was never meant to.
The flag is true. Why is my Service still flagged?
Because you have a custom default_template_sid. The flag appends the warning to the built-in body; a custom template sends whatever text it was approved with. The flag and the body are two different things, and only reading the template settles it.
What if the script cannot see the template?
It reports unresolved rather than guessing. A template SID that is not in the list your key can read is missing information, not evidence. Calling it covered would hide a gap and calling it broken would send somebody to review a template that is probably correct.
What does dtmf_input_required have to do with this?
It is the same problem on the voice channel. Without it, the call reads the code out to whatever answers, including a voicemail box, which then stores it as a recording behind a PIN. The script only raises it when the attempts show the call channel actually in use.
Can I write my own warning wording?
Yes, through a template, and that is the state this script asks you to read rather than trust. Custom wording is often better than the default because it can name your brand. It is only a problem when the customisation quietly dropped the caution line on its way through review.
Related field notes
- A Verify Service issuing four-digit codes
- Verify sending SMS to a line that cannot receive it
- Verify conversion collapsing in one country
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
- Verify Templates — 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.