Diagnostic Twilio
20003 on a read key: dead credential or a real boundary
Every other note in this section opens by telling you to run its script with an API Key that has read access. This is the one you run when that key comes back 401 with a body whose code is 20003, and you have to decide, before changing anything, whether the credential is broken or whether it just met a wall it was always going to meet.
GET /2010-04-01/Accounts/{AccountSid}.json. If that returns 401 with code 20003, the credential cannot read the account at all and the problem is the credential or the SID beside it. If it returns 200, the credential is alive and any 20003 you saw elsewhere is a scope boundary, not a fault.
Two boundaries account for most of them. GET /2010-04-01/Accounts/{AccountSid}/Keys.json and GET /2010-04-01/Accounts.json require a Main API Key; a Standard key gets 20003 on both, permanently and by design. Also compare the sid in the response body against the SID you authenticated with: if they differ, you have crossed a parent and a subaccount.
The problem in plain words
20003 is one code covering a set of unrelated causes, and it arrives with no detail that separates them. The credential could be deleted, or from a different region, or belong to a subaccount while the path names the parent. The secret could have picked up a newline on its way through a secrets manager. A proxy in front of your egress could be stripping the Authorization header entirely, which produces exactly the same response as having no credential at all. Or the key could be working perfectly and simply not be allowed to read the specific resource you asked for.
That last case is the one that wastes the most time, because it looks like all the others and it is not a fault. The response is identical: 401, 20003, Authenticate. So somebody rotates a healthy key, or widens its permissions, or files a support ticket, when the correct action was to stop calling that endpoint from that credential.
There is a second impostor nearby. A suspended account returns 403 with 20005, which reads as a permissions failure to anything that is only checking for an unhappy status code. It is not one; it is an account that has stopped, and no credential change will move it.
Why it happens
Main and Standard keys are not the same key. A Standard key cannot read Keys.json or the account list at Accounts.json; those need a Main key. This is the boundary a read-only credential most often hits, and it is worth naming precisely, because "my key does not have permission" is usually true and usually fine.
The account SID in the path is half the credential. Basic auth carries the key SID and secret, but the resource you read is chosen by the SID in the URL. A subaccount's key against the parent's SID authenticates fine and is refused, so the useful check is to compare the sid that comes back against the one you sent.
A stripped header and a wrong password look identical. Egress proxies, service meshes and some CDN configurations drop or rewrite Authorization. From your side the credential is perfect; from Twilio's side there was no credential. The tell is a 401 whose body does not carry 20003 at all, which is why the script reads the body rather than only the status.
Whitespace is a real and invisible cause. A secret with a trailing newline is a different secret. This is the one failure you can find without making a request at all, so the script checks the shape of what it was given before it spends a round trip proving the obvious.
Nothing on the key tells you what it can do. Keys.json lists sid, friendly_name, date_created and date_updated. There is no permissions field, no type field and no last-used timestamp, so the only way to know what a key can read is to read something with it. That is a genuine blind spot, and it is the reason this check is a probe rather than a lookup.
The fix, as a flow
The script probes the account resource first because it is the narrowest read the credential could be allowed at all: if that answers, every 20003 further out is a boundary rather than a fault.
How to fix it
Check the shape of the credential before spending a request
The username must be an SK key SID and the account SID an AC. A username beginning AC means the password beside it is the account auth token, which is a separate finding. Leading or trailing whitespace on either half is enough to produce 20003 on its own.
Probe the account resource first
GET /2010-04-01/Accounts/{AccountSid}.json is the narrowest useful read. A 200 proves the credential authenticates and is scoped to that account. A 401 with 20003 here is conclusive: nothing else you try will work either.
Compare the SID that comes back with the one you sent
The response body carries sid and owner_account_sid. If sid is not the SID you put in the path, you are authenticated against a different account than you meant — almost always a parent and child crossed over. That is a configuration fix, not a credential fix.
Probe the two Main-key resources deliberately
GET /2010-04-01/Accounts/{AccountSid}/Keys.json and GET /2010-04-01/Accounts.json. If the account read succeeded and these two return 20003, you are holding a Standard key and this is the documented boundary rather than a fault. Record it and stop calling those endpoints with this credential.
Separate 20005 out before you touch the credential
A 403 carrying 20005 is an account that is not active. Rotating keys will not fix it, and treating it as a permissions problem is how an outage gets an extra hour added to it. The script names it explicitly rather than folding it into the failure bucket.
How to check it worked
Re-run the script with the corrected credential. A key that can read everything this section needs reports read-ok; a working read-only key that is Standard reports scoped-key, which is also a pass.
python3 twilio_read_credential_check.py
# read-ok account, keys and accounts all readable with this credential.
The full code
Three GETs, deliberately chosen: the account, then the two resources that need a Main key. Unlike every other script in this section, this one does not raise on a 401 — the 401 is the measurement. Both pure functions are worth testing offline: the shape check that runs before any request, and the classifier that turns three status codes into one of six answers.
"""Explain a Twilio 20003: dead credential, crossed SID, or a scope boundary.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. Unlike the other scripts here it does not
abort on a 401, because the 401 is the thing being measured.
"""
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_read_credential_check")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
PERMISSION_DENIED = 20003
ACCOUNT_NOT_ACTIVE = 20005
# Resources a Standard API Key is not allowed to read. A Main key can; a Standard
# key gets 20003 on both, permanently, and that is a documented boundary rather
# than a broken credential. Everything else this section reads is fine on either.
MAIN_KEY_ONLY = ("keys", "accounts")
def credential_shape(account_sid, key_sid, secret):
"""Judge the credential without making a request. Pure.
Whitespace and a wrong username are the two causes of 20003 that can be
found for free, and finding them for free matters: a trailing newline on a
secret is invisible in every log you will look at afterwards.
Returns (state, detail).
"""
if not (account_sid and key_sid and secret):
return ("missing",
"set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET.")
for name, value in (("TWILIO_ACCOUNT_SID", account_sid),
("TWILIO_API_KEY", key_sid),
("TWILIO_API_SECRET", secret)):
if value != value.strip():
return ("whitespace",
"%s has leading or trailing whitespace. A secret with a "
"trailing newline is a different secret, and Twilio answers "
"%d for it." % (name, PERMISSION_DENIED))
if key_sid.strip().upper().startswith("AC"):
return ("auth-token",
"the username is an account SID, so the password beside it is "
"the account auth token rather than an API key secret.")
if not key_sid.strip().upper().startswith("SK"):
return ("not-a-key",
"the username is neither an SK API Key SID nor an AC account "
"SID, so nothing on the Twilio side will match it.")
if not account_sid.strip().upper().startswith("AC"):
return ("bad-account-sid",
"TWILIO_ACCOUNT_SID is not an AC SID. The account in the URL "
"path is half of what authorises the read.")
return ("ok", "an SK key SID against an AC account SID.")
def verdict(probes, requested_sid=None, returned_sid=None):
"""Turn the probe results into one answer. Pure, so every outcome can be
exercised without a network.
probes: {name: (http_status, twilio_code_or_None)} for "account" and, when
the account read succeeded, the Main-key-only resources.
Returns (state, detail).
"""
account = probes.get("account")
if account is None:
return ("unknown", "the account resource was never probed.")
status, code = account
if status == 403 and code == ACCOUNT_NOT_ACTIVE:
return ("account-not-active",
"403 with %d. This is not a permissions problem: the account is "
"suspended or closed, and no credential change will move it."
% ACCOUNT_NOT_ACTIVE)
if status == 401 and code == PERMISSION_DENIED:
return ("dead-credential",
"401 with %d on the account resource itself. The key is "
"deleted, from another account or another region, or the "
"secret is wrong. Nothing else will read either."
% PERMISSION_DENIED)
if status == 401:
return ("unauthenticated",
"401 with no %d in the body. Twilio saw no usable credential at "
"all, which is what a proxy stripping the Authorization header "
"looks like from this side." % PERMISSION_DENIED)
if status != 200:
return ("http-error",
"%s from the account resource, which is neither an auth answer "
"nor a healthy one. Retry before drawing conclusions." % status)
if requested_sid and returned_sid and requested_sid != returned_sid:
return ("wrong-account",
"authenticated, but the account read back is %s rather than the "
"%s you asked for: a parent and a subaccount have been crossed."
% (returned_sid, requested_sid))
denied = [name for name in MAIN_KEY_ONLY
if probes.get(name) and probes[name][0] == 401
and probes[name][1] == PERMISSION_DENIED]
if denied:
return ("scoped-key",
"the account reads fine, and %s returned %d. That is a Standard "
"API Key meeting the Main-key boundary, not a broken "
"credential. Every check in this section works on this key "
"except the ones that read keys or list accounts."
% (" and ".join(denied), PERMISSION_DENIED))
return ("read-ok", "account, keys and accounts all readable with this "
"credential.")
def probe(session, url):
"""One GET, reduced to (status, twilio code). Nothing here raises: a 401 is
the measurement, not an error."""
r = session.get(url, timeout=30)
code = None
try:
code = r.json().get("code")
except ValueError:
pass
return (r.status_code, code)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--skip-main-key-probes", action="store_true",
help="stop after the account read")
args = ap.parse_args()
account_sid = os.environ.get("TWILIO_ACCOUNT_SID", "")
key_sid = os.environ.get("TWILIO_API_KEY", "")
secret = os.environ.get("TWILIO_API_SECRET", "")
shape, detail = credential_shape(account_sid, key_sid, secret)
if shape != "ok":
log.error("%-16s %s", shape, detail)
return 2
log.info("%-16s %s", "shape", detail)
session = requests.Session()
session.auth = (key_sid.strip(), secret.strip())
account_sid = account_sid.strip()
url = "%s/Accounts/%s.json" % (BASE, account_sid)
probes = {"account": probe(session, url)}
returned = None
if probes["account"][0] == 200:
returned = session.get(url, timeout=30).json().get("sid")
if not args.skip_main_key_probes:
probes["keys"] = probe(
session, "%s/Accounts/%s/Keys.json" % (BASE, account_sid))
probes["accounts"] = probe(session, "%s/Accounts.json" % BASE)
state, detail = verdict(probes, account_sid, returned)
line = "%-18s %s" % (state, detail)
if state in ("read-ok", "scoped-key"):
log.info(line)
return 0
log.warning(line)
if state == "account-not-active":
log.warning(" repair: Console -> Billing. Read the account status "
"before touching any credential.")
elif state == "wrong-account":
log.warning(" repair: use the SID of the account this key belongs to "
"in the URL path, or issue a key on the account you meant.")
elif state == "unauthenticated":
log.warning(" repair: check whether anything between this process and "
"api.twilio.com rewrites or drops the Authorization header.")
else:
log.warning(" repair: Console -> Account -> API keys & tokens -> "
"create a Main API key, and use the SK SID and its secret "
"as the basic-auth pair against this account SID.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Explain a Twilio 20003: dead credential, crossed SID, or a scope boundary.
*
* Read only. GET requests and nothing else. Unlike the other scripts here it
* does not throw on a 401, because the 401 is the thing being measured.
*/
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const PERMISSION_DENIED = 20003;
const ACCOUNT_NOT_ACTIVE = 20005;
// Resources a Standard API Key is not allowed to read. A Main key can; a
// Standard key gets 20003 on both, permanently and by design.
const MAIN_KEY_ONLY = ['keys', 'accounts'];
/**
* Judge the credential without making a request. Pure. Whitespace and a wrong
* username are the two causes of 20003 that can be found for free.
* Returns [state, detail].
*/
export function credentialShape(accountSid, keySid, secret) {
if (!accountSid || !keySid || !secret) {
return ['missing',
'set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET.'];
}
for (const [name, value] of [['TWILIO_ACCOUNT_SID', accountSid],
['TWILIO_API_KEY', keySid],
['TWILIO_API_SECRET', secret]]) {
if (value !== value.trim()) {
return ['whitespace',
`${name} has leading or trailing whitespace. A secret with a trailing ` +
`newline is a different secret, and Twilio answers ${PERMISSION_DENIED} ` +
'for it.'];
}
}
if (keySid.trim().toUpperCase().startsWith('AC')) {
return ['auth-token',
'the username is an account SID, so the password beside it is the ' +
'account auth token rather than an API key secret.'];
}
if (!keySid.trim().toUpperCase().startsWith('SK')) {
return ['not-a-key',
'the username is neither an SK API Key SID nor an AC account SID, so ' +
'nothing on the Twilio side will match it.'];
}
if (!accountSid.trim().toUpperCase().startsWith('AC')) {
return ['bad-account-sid',
'TWILIO_ACCOUNT_SID is not an AC SID. The account in the URL path is ' +
'half of what authorises the read.'];
}
return ['ok', 'an SK key SID against an AC account SID.'];
}
/**
* Turn the probe results into one answer. Pure, so every outcome can be
* exercised without a network. Returns [state, detail].
*/
export function verdict(probes, requestedSid = null, returnedSid = null) {
const account = probes.account;
if (!account) return ['unknown', 'the account resource was never probed.'];
const [status, code] = account;
if (status === 403 && code === ACCOUNT_NOT_ACTIVE) {
return ['account-not-active',
`403 with ${ACCOUNT_NOT_ACTIVE}. This is not a permissions problem: the ` +
'account is suspended or closed, and no credential change will move it.'];
}
if (status === 401 && code === PERMISSION_DENIED) {
return ['dead-credential',
`401 with ${PERMISSION_DENIED} on the account resource itself. The key ` +
'is deleted, from another account or another region, or the secret is ' +
'wrong. Nothing else will read either.'];
}
if (status === 401) {
return ['unauthenticated',
`401 with no ${PERMISSION_DENIED} in the body. Twilio saw no usable ` +
'credential at all, which is what a proxy stripping the Authorization ' +
'header looks like from this side.'];
}
if (status !== 200) {
return ['http-error',
`${status} from the account resource, which is neither an auth answer ` +
'nor a healthy one. Retry before drawing conclusions.'];
}
if (requestedSid && returnedSid && requestedSid !== returnedSid) {
return ['wrong-account',
`authenticated, but the account read back is ${returnedSid} rather than ` +
`the ${requestedSid} you asked for: a parent and a subaccount have been ` +
'crossed.'];
}
const denied = MAIN_KEY_ONLY.filter(
(name) => probes[name] && probes[name][0] === 401
&& probes[name][1] === PERMISSION_DENIED);
if (denied.length) {
return ['scoped-key',
`the account reads fine, and ${denied.join(' and ')} returned ` +
`${PERMISSION_DENIED}. That is a Standard API Key meeting the Main-key ` +
'boundary, not a broken credential. Every check in this section works on ' +
'this key except the ones that read keys or list accounts.'];
}
return ['read-ok', 'account, keys and accounts all readable with this credential.'];
}
function authHeader(key, secret) {
return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}
/** One GET, reduced to [status, twilio code]. Nothing here throws. */
async function probe(auth, url) {
const res = await fetch(url, { headers: { Authorization: auth } });
let code = null;
try {
code = (await res.json()).code ?? null;
} catch {
code = null;
}
return [res.status, code];
}
async function main() {
const accountSid = process.env.TWILIO_ACCOUNT_SID ?? '';
const keySid = process.env.TWILIO_API_KEY ?? '';
const secret = process.env.TWILIO_API_SECRET ?? '';
const [shape, shapeDetail] = credentialShape(accountSid, keySid, secret);
if (shape !== 'ok') {
console.error(`${shape.padEnd(16)} ${shapeDetail}`);
process.exitCode = 2;
return;
}
console.log(`${'shape'.padEnd(16)} ${shapeDetail}`);
const auth = authHeader(keySid.trim(), secret.trim());
const sid = accountSid.trim();
const url = `${BASE}/Accounts/${sid}.json`;
const probes = { account: await probe(auth, url) };
let returned = null;
if (probes.account[0] === 200) {
const res = await fetch(url, { headers: { Authorization: auth } });
returned = (await res.json()).sid ?? null;
if (!process.argv.includes('--skip-main-key-probes')) {
probes.keys = await probe(auth, `${BASE}/Accounts/${sid}/Keys.json`);
probes.accounts = await probe(auth, `${BASE}/Accounts.json`);
}
}
const [state, detail] = verdict(probes, sid, returned);
const line = `${state.padEnd(18)} ${detail}`;
if (state === 'read-ok' || state === 'scoped-key') {
console.log(line);
return;
}
console.warn(line);
if (state === 'account-not-active') {
console.warn(' repair: Console -> Billing. Read the account status before ' +
'touching any credential.');
} else if (state === 'wrong-account') {
console.warn(' repair: use the SID of the account this key belongs to in ' +
'the URL path, or issue a key on the account you meant.');
} else if (state === 'unauthenticated') {
console.warn(' repair: check whether anything between this process and ' +
'api.twilio.com rewrites or drops the Authorization header.');
} else {
console.warn(' repair: Console -> Account -> API keys & tokens -> create a ' +
'Main API key, and use the SK SID and its secret as the ' +
'basic-auth pair against this account SID.');
}
process.exitCode = 1;
}
// 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 whole value of this note is one distinction, so that is the pair of cases to pin: 20003 on the account read is a dead credential, and 20003 on Keys.json after a successful account read is a Standard key doing what Standard keys do. The 403 with 20005 case is here for the same reason it is in the code, because an unhappy status code is not automatically a permissions problem.
from twilio_read_credential_check import credential_shape, verdict
def test_20003_on_the_account_read_is_a_dead_credential():
state, detail = verdict({"account": (401, 20003)})
assert state == "dead-credential"
assert "Nothing else will read" in detail
def test_20003_only_on_the_main_key_resources_is_a_boundary_not_a_fault():
state, detail = verdict({"account": (200, None), "keys": (401, 20003),
"accounts": (401, 20003)})
assert state == "scoped-key"
assert "not a broken credential" in detail
def test_a_403_with_20005_is_a_suspended_account_not_a_permission_problem():
state, detail = verdict({"account": (403, 20005)})
assert state == "account-not-active"
assert "suspended" in detail
def test_a_401_without_20003_reads_as_a_stripped_header():
state, detail = verdict({"account": (401, None)})
assert state == "unauthenticated"
assert "Authorization header" in detail
def test_a_different_sid_coming_back_is_a_crossed_parent_and_child():
state, detail = verdict({"account": (200, None)},
requested_sid="AC1", returned_sid="AC2")
assert state == "wrong-account"
assert "crossed" in detail
def test_everything_readable_passes():
state, _ = verdict({"account": (200, None), "keys": (200, None),
"accounts": (200, None)}, "AC1", "AC1")
assert state == "read-ok"
def test_a_non_auth_error_is_not_reported_as_a_credential_problem():
assert verdict({"account": (503, None)})[0] == "http-error"
def test_trailing_whitespace_is_caught_before_any_request():
state, detail = credential_shape("AC1", "SK1", "secret\n")
assert state == "whitespace"
assert "TWILIO_API_SECRET" in detail
def test_an_account_sid_as_the_username_means_the_auth_token():
state, _ = credential_shape("AC1", "AC1", "secret")
assert state == "auth-token"
def test_a_well_formed_pair_passes_the_shape_check():
assert credential_shape("AC1", "SK1", "secret")[0] == "ok"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { credentialShape, verdict } from './twilio-read-credential-check.mjs';
test('20003 on the account read is a dead credential', () => {
const [state, detail] = verdict({ account: [401, 20003] });
assert.equal(state, 'dead-credential');
assert.match(detail, /Nothing else will read/);
});
test('20003 only on the main key resources is a boundary not a fault', () => {
const [state, detail] = verdict({
account: [200, null], keys: [401, 20003], accounts: [401, 20003],
});
assert.equal(state, 'scoped-key');
assert.match(detail, /not a broken credential/);
});
test('a 403 with 20005 is a suspended account not a permission problem', () => {
const [state, detail] = verdict({ account: [403, 20005] });
assert.equal(state, 'account-not-active');
assert.match(detail, /suspended/);
});
test('a 401 without 20003 reads as a stripped header', () => {
const [state, detail] = verdict({ account: [401, null] });
assert.equal(state, 'unauthenticated');
assert.match(detail, /Authorization header/);
});
test('a different sid coming back is a crossed parent and child', () => {
const [state, detail] = verdict({ account: [200, null] }, 'AC1', 'AC2');
assert.equal(state, 'wrong-account');
assert.match(detail, /crossed/);
});
test('everything readable passes', () => {
const [state] = verdict({
account: [200, null], keys: [200, null], accounts: [200, null],
}, 'AC1', 'AC1');
assert.equal(state, 'read-ok');
});
test('a non auth error is not reported as a credential problem', () => {
assert.equal(verdict({ account: [503, null] })[0], 'http-error');
});
test('trailing whitespace is caught before any request', () => {
const [state, detail] = credentialShape('AC1', 'SK1', 'secret\n');
assert.equal(state, 'whitespace');
assert.match(detail, /TWILIO_API_SECRET/);
});
test('an account sid as the username means the auth token', () => {
assert.equal(credentialShape('AC1', 'AC1', 'secret')[0], 'auth-token');
});
test('a well formed pair passes the shape check', () => {
assert.equal(credentialShape('AC1', 'SK1', 'secret')[0], 'ok');
});
FAQ
Which resources can a read-only key legitimately not see?
The two that need a Main API Key: the key list at Accounts/{AccountSid}/Keys.json and the account list at Accounts.json. A Standard key gets 20003 on both, always, and no amount of rotation changes that. Everything else the scripts in this section read - phone numbers, messages, calls, Messaging Services, Verify services, alerts, usage - is readable on a Standard key. A key's own secret is also unreadable after creation, by anyone, including a Main key.
How do I tell a scope boundary from a broken key without guessing?
By probing in order. Read the account resource first, because it is the narrowest thing the credential could possibly be allowed to read. If that succeeds, the credential is alive and correctly scoped to that account, so any 20003 further out is a boundary. If it fails with 20003, nothing else is worth trying.
The key works locally and 20003s in production. Same key, same SID.
Then something is changing the request between your process and Twilio, and the usual suspect is an egress proxy or service mesh that drops or rewrites the Authorization header. That produces a 401 whose body has no 20003 in it, because Twilio saw no credential rather than a bad one. The other candidate is the secret arriving with a newline from whatever injected it, which is why the script checks for whitespace before it makes a request.
Why does the script compare the SID it sent with the SID it got back?
Because a subaccount key against a parent SID, or the reverse, authenticates and then reads the wrong thing or nothing at all. The response body carries sid and owner_account_sid, so the comparison is free, and it catches a class of mistake that otherwise looks exactly like a permissions problem in a multi-tenant setup.
Is 403 with 20005 the same problem in a different colour?
No, and conflating them costs time during an outage. 20003 is about who you are; 20005 is about whether the account is allowed to do anything at all. A suspended or closed account returns 20005 to a perfectly good credential, and the repair is billing or a support ticket rather than a new key.
Related field notes
- No API keys exist, so the auth token is the credential
- Years-old API keys still live with nobody owning them
- The account itself is suspended or closed
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.
- Error 20003: permission denied — Twilio Docs
- API keys — Twilio Docs
- Key resource (2010-04-01) — Twilio Docs
- Account resource — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.