Diagnostic Twilio
years-old API keys are still live with nobody owning them
There are eleven keys on the account. Four have names. One of the unnamed ones was created in March 2023 by a contractor whose email address stopped working two years ago, and nobody can say what it authenticates. It has never expired, because Twilio keys do not. It has no last-used timestamp, because that field does not exist. And deleting it to find out will also invalidate every Access Token it ever signed.
Read GET /2010-04-01/Accounts/{AccountSid}/Keys.json and, per entry, look at sid, friendly_name, date_created and date_updated. Flag any key whose name is empty or a placeholder, and any key created before your rotation window.
Those four fields are all there is. There is no expiry, no last-used timestamp and no per-key request count, so the audit is about ownership rather than activity: a named key belongs to something and can be retired deliberately, an unnamed one cannot be retired at all.
date_created comes back in RFC 2822 on this API, not ISO 8601. Parse it as such or your age comparison silently returns nothing.
The problem in plain words
API keys accumulate and never leave. One from the original integration, one from a load test, one from the Zapier connector somebody set up for the sales team, three from a migration that took two attempts, and several with no name at all because the console does not insist on one. Each of them is a full-privilege path into an account that can send messages and spend money, held by a person or a process nobody has an inventory of.
The reason this stays unfixed is not laziness, it is that deletion is genuinely dangerous and nobody can measure the danger. Removing a key immediately revokes REST access for whatever was using it, with a 20003 and no grace period, and it also invalidates every Access Token that was signed with that key's secret — so browser and mobile clients holding a live Voice or Video token drop as well. The API will not tell you whether anything is using the key. So the safe move looks like leaving it, and the set grows for another year.
Why it happens
Keys have no lifecycle. They do not expire, they are not reviewed, and nothing in the account ages them out. Creating one is a two-field form; retiring one is an act of research. That asymmetry is the whole reason the count only ever goes up.
There is no last-used field, on any Twilio resource. This is the blind spot that shapes the entire check. You cannot ask which keys are live, so the audit has to be about whether a human can account for the key, which means the name is the control and an empty name is the finding.
date_updated is not activity. It moves when the friendly name is edited, and not when the key is used. Reading it as a last-seen timestamp is the specific mistake that keeps a dead key alive for another cycle, because a key renamed last month looks recently active and is not.
Deleting reaches further than REST. The key's secret signs Access Tokens for the client SDKs. Delete the key and every token signed with it stops validating, including tokens already issued to browsers that are mid-call. A key used only by a nightly script and a key underpinning your softphone look identical in Keys.json.
The dates are in a format that quietly breaks comparisons. The 2010-04-01 API returns Tue, 18 Apr 2023 09:12:00 +0000. Newer Twilio domains return ISO 8601. A parser written for one and fed the other returns nothing, and a report with no findings reads exactly like a clean account.
The fix, as a flow
There is no last-used field on a Twilio key, so the script cannot ask which keys are live. It asks whether a human can account for them, which makes friendly_name the control and an empty name the finding.
How to fix it
List every key, following the pages
GET /2010-04-01/Accounts/{AccountSid}/Keys.json?PageSize=50 and follow next_page_uri, which is a path rather than an absolute URL. Do this per subaccount too: keys belong to an account, and a subaccount's keys are invisible from the parent's list.
Parse the dates for the API you are actually calling
date_created and date_updated are RFC 2822 here. Python's email.utils.parsedate_to_datetime reads them; datetime.fromisoformat does not. Normalise to UTC before comparing, because a naive datetime compared against an aware one raises rather than returning a wrong answer, which is at least the better failure.
Judge on the name first and the age second
A key called billing-worker-prod that is three years old is a rotation task. A key with no name at all, or called Untitled, is a different and worse thing: there is no path from that key to a person, so it cannot be retired safely at any age.
Rename before you delete
friendly_name is the only ownership record the resource has, and renaming is reversible in a way that deleting is not. Give every key an owner and a purpose, wait a cycle to see whether anybody claims one, and only then remove what is still unclaimed.
Delete one, watch for 20003, then do the next
DELETE /2010-04-01/Accounts/{AccountSid}/Keys/{KeySid} takes effect immediately and cannot be undone; a new key is a new SID and a new secret. Remove one key per change window and watch the Alerts for 20003, and remember that anything holding an Access Token signed by that key loses it at the same moment.
How to check it worked
Re-run the script. Every key should report current with a name that identifies its owner.
python3 twilio_api_key_audit.py --max-age-days 365
# 6 key(s), 0 unowned, 0 past the rotation window
The full code
One paginated GET and two pure functions: the date parser, because the RFC 2822 format on this API is the thing that silently empties the report, and the per-key verdict, because the rules about names and ages are the content of the note. An API Key with read access is enough; the audit will find itself in its own output, which is a good reason to have named it.
"""Report Twilio API keys that are old, unnamed, or otherwise unaccounted for.
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 deleting a key revokes REST access immediately and invalidates every
Access Token that key's secret ever signed.
"""
import argparse
import datetime
import logging
import os
import sys
from email.utils import parsedate_to_datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_api_key_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
# Names that identify nothing. A key wearing one of these cannot be traced to an
# owner, which is worse than a key that is merely old.
PLACEHOLDER_NAMES = {"", "untitled", "untitled key", "default", "key", "my key",
"test", "temp", "tmp", "quickstart", "new key", "api key"}
def parse_date(value):
"""Parse a Twilio timestamp into an aware UTC datetime.
The 2010-04-01 API returns RFC 2822 ("Tue, 18 Apr 2023 09:12:00 +0000") while
the newer Twilio domains return ISO 8601. Branch on the first characters
rather than letting one parser guess at the other's format: a parser that
returns nothing produces a report with no findings, which reads exactly like
a clean account.
"""
text = str(value or "").strip()
if not text:
return None
if text[:4].isdigit():
try:
parsed = datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
else:
try:
parsed = parsedate_to_datetime(text)
except (TypeError, ValueError):
return None
if parsed is None:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
return parsed.astimezone(datetime.timezone.utc)
def age_days(key, now):
"""Days since the key was created, or None when the date will not parse."""
created = parse_date(key.get("date_created"))
if created is None:
return None
return (now - created).days
def verdict(key, now, max_age_days=365):
"""Classify one API key. Pure, so the rules can be tested without a network.
There is no last-used timestamp on a Twilio key, on any Twilio resource. So
this cannot ask whether a key is in use; it asks whether a human can account
for it, which makes the name the control and an empty name the finding.
Returns (state, detail).
"""
name = str(key.get("friendly_name") or "").strip()
sid = str(key.get("sid") or "").strip()
if name.lower() in PLACEHOLDER_NAMES or (sid and name == sid):
return ("unowned",
"friendly_name is %s: nothing on the account records what this "
"key authenticates, and a key nobody can account for is a key "
"nobody will ever be willing to delete." % (name or "empty"))
age = age_days(key, now)
if age is None:
return ("undated",
"date_created did not parse (%s): this API returns RFC 2822, not "
"ISO 8601. Treat the key as the oldest on the account until "
"somebody establishes otherwise." % (key.get("date_created") or "empty"))
if age > max_age_days:
created = parse_date(key.get("date_created"))
renamed = parse_date(key.get("date_updated"))
untouched = (renamed is not None and created is not None and renamed <= created)
return ("stale",
"%s, created %d days ago, past the %d day rotation window%s."
% (name, age, max_age_days,
"; date_updated has never moved, so nobody has even renamed it"
if untouched else ""))
return ("current", "%s, created %d days ago." % (name, age))
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 list_keys(session, account, limit=500):
"""Page Keys.json. next_page_uri is a path, not an absolute URL."""
url = "%s/Accounts/%s/Keys.json" % (BASE, account)
params = {"PageSize": 50}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("keys", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-age-days", type=int, default=365,
help="keys older than this are reported for rotation")
ap.add_argument("--as-of", default=None,
help="ISO date to age keys against, for a reproducible run")
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
now = parse_date(args.as_of) if args.as_of else None
if now is None:
now = datetime.datetime.now(datetime.timezone.utc)
session = requests.Session()
session.auth = (key, secret)
keys = list_keys(session, account)
if not keys:
log.info("no API keys on this account: see the note on the auth token")
return 0
unowned = stale = 0
for entry in keys:
state, detail = verdict(entry, now, args.max_age_days)
line = "%-8s %s %s" % (state, entry.get("sid", "?"), detail)
if state == "current":
log.info(line)
continue
if state == "unowned":
unowned += 1
else:
stale += 1
log.warning(line)
log.warning(" repair: rename it first, POST %s/Accounts/%s/Keys/%s.json "
"FriendlyName={owner}-{service}; once a cycle has passed with "
"nobody claiming it, remove it with DELETE on the same resource",
BASE, account, entry.get("sid", "?"))
log.warning(" deleting also invalidates every Access Token signed with "
"this key's secret, so client SDK sessions drop with it")
log.info("%d key(s), %d unowned, %d past the rotation window",
len(keys), unowned, stale)
return 1 if (unowned or stale) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio API keys that are old, unnamed, or otherwise unaccounted for.
*
* 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 removing a key revokes REST access immediately and
* invalidates every Access Token that key's secret ever signed.
*/
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
// Names that identify nothing. A key wearing one of these cannot be traced to an
// owner, which is worse than a key that is merely old.
const PLACEHOLDER_NAMES = new Set(['', 'untitled', 'untitled key', 'default', 'key',
'my key', 'test', 'temp', 'tmp', 'quickstart', 'new key', 'api key']);
/**
* Parse a Twilio timestamp. The 2010-04-01 API returns RFC 2822 while the newer
* domains return ISO 8601; Date.parse reads both, and Twilio always sends an
* explicit offset, so there is no local-time ambiguity to guard against.
*/
export function parseDate(value) {
const text = String(value ?? '').trim();
if (!text) return null;
const ms = Date.parse(text);
return Number.isNaN(ms) ? null : new Date(ms);
}
/** Days since the key was created, or null when the date will not parse. */
export function ageDays(key, now) {
const created = parseDate(key.date_created);
if (created === null) return null;
return Math.floor((now.getTime() - created.getTime()) / 86400000);
}
/**
* Classify one API key. Pure, so the rules can be tested without a network.
*
* There is no last-used timestamp on a Twilio key, so this cannot ask whether a
* key is in use. It asks whether a human can account for it, which makes the
* name the control and an empty name the finding. Returns [state, detail].
*/
export function verdict(key, now, maxAgeDays = 365) {
const name = String(key.friendly_name ?? '').trim();
const sid = String(key.sid ?? '').trim();
if (PLACEHOLDER_NAMES.has(name.toLowerCase()) || (sid && name === sid)) {
return ['unowned',
`friendly_name is ${name || 'empty'}: nothing on the account records what ` +
'this key authenticates, and a key nobody can account for is a key nobody ' +
'will ever be willing to delete.'];
}
const age = ageDays(key, now);
if (age === null) {
return ['undated',
`date_created did not parse (${key.date_created || 'empty'}): this API ` +
'returns RFC 2822, not ISO 8601. Treat the key as the oldest on the account ' +
'until somebody establishes otherwise.'];
}
if (age > maxAgeDays) {
const created = parseDate(key.date_created);
const renamed = parseDate(key.date_updated);
const untouched = renamed !== null && created !== null
&& renamed.getTime() <= created.getTime();
return ['stale',
`${name}, created ${age} days ago, past the ${maxAgeDays} day rotation ` +
`window${untouched ? '; date_updated has never moved, so nobody has even renamed it' : ''}.`];
}
return ['current', `${name}, created ${age} days ago.`];
}
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();
}
export async function listKeys(auth, account, limit = 500) {
let url = `${BASE}/Accounts/${account}/Keys.json`;
let params = { PageSize: 50 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.keys ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out.slice(0, limit);
}
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 flag = process.argv.indexOf('--max-age-days');
const maxAgeDays = flag === -1 ? 365 : Number.parseInt(process.argv[flag + 1], 10);
const now = new Date();
const auth = authHeader(key, secret);
const keys = await listKeys(auth, account);
if (keys.length === 0) {
console.log('no API keys on this account: see the note on the auth token');
return;
}
let unowned = 0;
let stale = 0;
for (const entry of keys) {
const [state, detail] = verdict(entry, now, maxAgeDays);
const line = `${state.padEnd(8)} ${entry.sid ?? '?'} ${detail}`;
if (state === 'current') { console.log(line); continue; }
if (state === 'unowned') unowned += 1; else stale += 1;
console.warn(line);
console.warn(` repair: rename it first, POST ${BASE}/Accounts/${account}/Keys/` +
`${entry.sid ?? '?'}.json FriendlyName={owner}-{service}; once a ` +
'cycle has passed with nobody claiming it, remove it with DELETE ' +
'on the same resource');
console.warn(' deleting also invalidates every Access Token signed with this ' +
"key's secret, so client SDK sessions drop with it");
}
console.log(`${keys.length} key(s), ${unowned} unowned, ${stale} past the rotation window`);
process.exitCode = (unowned || stale) ? 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 date parsing gets its own tests because it is the failure that hides every other one: hand an ISO parser the RFC 2822 string this API actually returns and it reports nothing, and nothing is indistinguishable from a tidy account. The rest pin the precedence — an unnamed key is a finding whatever its age — and the date_updated case, which is the field most often mistaken for activity.
import datetime
from twilio_api_key_audit import age_days, parse_date, verdict
NOW = datetime.datetime(2026, 8, 30, tzinfo=datetime.timezone.utc)
def make(**kw):
key = {"sid": "SK00000000000000000000000000000001",
"friendly_name": "billing-worker-prod",
"date_created": "Sat, 01 Aug 2026 09:12:00 +0000",
"date_updated": "Sat, 01 Aug 2026 09:12:00 +0000"}
key.update(kw)
return key
def test_the_2010_api_returns_rfc_2822_and_it_has_to_parse():
parsed = parse_date("Tue, 18 Apr 2023 09:12:00 +0000")
assert parsed == datetime.datetime(2023, 4, 18, 9, 12,
tzinfo=datetime.timezone.utc)
def test_iso_8601_from_the_newer_domains_parses_too():
parsed = parse_date("2023-04-18T09:12:00Z")
assert parsed == datetime.datetime(2023, 4, 18, 9, 12,
tzinfo=datetime.timezone.utc)
def test_an_unparseable_date_is_none_rather_than_a_wrong_answer():
assert parse_date("last tuesday") is None
assert parse_date("") is None
assert parse_date(None) is None
def test_age_is_measured_in_whole_days_from_the_created_date():
assert age_days(make(date_created="Thu, 30 Jul 2026 00:00:00 +0000"), NOW) == 31
def test_a_recently_created_named_key_is_current():
state, _ = verdict(make(), NOW)
assert state == "current"
def test_an_empty_friendly_name_is_unowned_whatever_its_age():
state, detail = verdict(make(friendly_name="",
date_created="Sat, 01 Aug 2026 09:12:00 +0000"), NOW)
assert state == "unowned"
assert "nobody can account for" in detail
def test_a_placeholder_name_counts_as_no_name():
assert verdict(make(friendly_name="Untitled"), NOW)[0] == "unowned"
assert verdict(make(friendly_name=" test "), NOW)[0] == "unowned"
def test_a_key_named_after_its_own_sid_records_nothing():
sid = "SK00000000000000000000000000000009"
assert verdict(make(sid=sid, friendly_name=sid), NOW)[0] == "unowned"
def test_a_named_key_past_the_window_is_stale():
state, detail = verdict(make(date_created="Wed, 15 Mar 2023 09:12:00 +0000",
date_updated="Wed, 15 Mar 2023 09:12:00 +0000"),
NOW, max_age_days=365)
assert state == "stale"
assert "never moved" in detail
def test_date_updated_after_creation_is_a_rename_not_activity():
# It moved, so the report drops the "never renamed" clause. It still says
# nothing about whether the key has ever been used.
state, detail = verdict(make(date_created="Wed, 15 Mar 2023 09:12:00 +0000",
date_updated="Mon, 06 Jan 2025 11:00:00 +0000"),
NOW, max_age_days=365)
assert state == "stale"
assert "never moved" not in detail
def test_a_key_whose_date_will_not_parse_is_reported_not_skipped():
state, detail = verdict(make(date_created="18/04/2023"), NOW)
assert state == "undated"
assert "RFC 2822" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ageDays, parseDate, verdict } from './twilio-api-key-audit.mjs';
const NOW = new Date('2026-08-30T00:00:00Z');
const make = (over = {}) => ({
sid: 'SK00000000000000000000000000000001',
friendly_name: 'billing-worker-prod',
date_created: 'Sat, 01 Aug 2026 09:12:00 +0000',
date_updated: 'Sat, 01 Aug 2026 09:12:00 +0000',
...over,
});
test('the 2010 api returns rfc 2822 and it has to parse', () => {
assert.equal(parseDate('Tue, 18 Apr 2023 09:12:00 +0000').toISOString(),
'2023-04-18T09:12:00.000Z');
});
test('iso 8601 from the newer domains parses too', () => {
assert.equal(parseDate('2023-04-18T09:12:00Z').toISOString(),
'2023-04-18T09:12:00.000Z');
});
test('an unparseable date is null rather than a wrong answer', () => {
assert.equal(parseDate('last tuesday'), null);
assert.equal(parseDate(''), null);
assert.equal(parseDate(null), null);
});
test('age is measured in whole days from the created date', () => {
assert.equal(ageDays(make({ date_created: 'Thu, 30 Jul 2026 00:00:00 +0000' }), NOW),
31);
});
test('a recently created named key is current', () => {
assert.equal(verdict(make(), NOW)[0], 'current');
});
test('an empty friendly name is unowned whatever its age', () => {
const [state, detail] = verdict(make({ friendly_name: '' }), NOW);
assert.equal(state, 'unowned');
assert.match(detail, /nobody can account for/);
});
test('a placeholder name counts as no name', () => {
assert.equal(verdict(make({ friendly_name: 'Untitled' }), NOW)[0], 'unowned');
assert.equal(verdict(make({ friendly_name: ' test ' }), NOW)[0], 'unowned');
});
test('a key named after its own sid records nothing', () => {
const sid = 'SK00000000000000000000000000000009';
assert.equal(verdict(make({ sid, friendly_name: sid }), NOW)[0], 'unowned');
});
test('a named key past the window is stale', () => {
const [state, detail] = verdict(make({
date_created: 'Wed, 15 Mar 2023 09:12:00 +0000',
date_updated: 'Wed, 15 Mar 2023 09:12:00 +0000',
}), NOW, 365);
assert.equal(state, 'stale');
assert.match(detail, /never moved/);
});
test('date_updated after creation is a rename not activity', () => {
const [state, detail] = verdict(make({
date_created: 'Wed, 15 Mar 2023 09:12:00 +0000',
date_updated: 'Mon, 06 Jan 2025 11:00:00 +0000',
}), NOW, 365);
assert.equal(state, 'stale');
assert.doesNotMatch(detail, /never moved/);
});
test('a key whose date will not parse is reported not skipped', () => {
const [state, detail] = verdict(make({ date_created: 'not a date at all' }), NOW);
assert.equal(state, 'undated');
assert.match(detail, /RFC 2822/);
});
FAQ
Why not just delete every key nobody claims?
Because deletion reaches further than REST access. The key's secret signs Access Tokens for the client SDKs, so removing it invalidates every token signed with it, including ones already issued to browsers and handsets. A key used by a nightly report and a key underpinning your softphone are indistinguishable in Keys.json, which is why renaming comes first and deleting comes one key per change window.
Is there really no way to see whether a key is being used?
Not from the API. There is no last-used timestamp, no per-key request count, and no field that maps a key to a deployment. That is the blind spot this check is built around: it cannot report activity, so it reports accountability instead. The nearest thing to evidence is deleting the key and watching for 20003, which is a test you run deliberately rather than discover.
Does date_updated tell me when the key was last used?
No. It moves when the friendly name is edited and at no other time. Treating it as a last-seen timestamp is the most common reason a dead key survives a review: it was renamed during a tidy-up eight months ago, so it looks recently active, and it has authenticated nothing since 2023.
What is a sensible rotation window?
Whatever you will actually keep to. A year is a defensible default for a small account and quarterly is normal where the keys map to deployments you can redeploy without ceremony. The number matters much less than every key having a name that says who owns it, because that is what turns rotation from research into a scheduled task.
What about the key this script is using?
It appears in its own report, which is a good argument for giving it a name like fieldnotes-readonly-audit before you run it. If your own audit credential shows up as unowned, that is the finding working correctly.
Related field notes
- Why the auth token should not be your credential
- No spend alarm on an account that can spend
- Numbers billed every month for carrying nothing
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.
- Key resource (2010-04-01) — Twilio Docs
- API keys — Twilio Docs
- Secure your Twilio account — Twilio Docs
- Webhook security and X-Twilio-Signature — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.