Diagnostic GitHub API
401 Bad credentials on every endpoint, even public ones
401 {"message":"Bad credentials"} on every endpoint you try, including ones that need no credential at all. The token is right there in the environment, it was working yesterday, and the error names nothing: not the account, not the scope, not which of the six things that produce this message actually happened.
GitHub returns two different 401 messages and they mean opposite things. Bad credentials means GitHub received something in the Authorization header, parsed it, and refused it. Requires authentication means GitHub received nothing and the endpoint needed something. One is a token problem; the other is a transport problem, and no amount of re-minting will fix the second.
The cheapest way to tell them apart is a control request against an endpoint that needs no credential at all. Send it once with the header and once without. A 200 without and a 401 with is proof the value you are sending is the thing being rejected. The script below runs that pair, checks that GitHub itself answered rather than something in front of it, and then asks the one question a valid token can still fail: is it the right account?
The problem in plain words
The message is identical for causes that have nothing to do with each other. A revoked token, a token that expired last night, a value truncated by a secrets store, a trailing newline from cat token.txt, a shell variable that expanded to empty, a token minted on a different account: all of them are Bad credentials. That is not GitHub being unhelpful, it is GitHub deliberately refusing to confirm anything about a credential it does not accept.
So the investigation goes in a circle. Someone re-mints the token, it still fails, and now there are two tokens and no more information. Someone tries a different endpoint, gets the same 401, and concludes the whole API is down. Someone pastes the token into curl by hand, it works, and the conclusion is "the script is broken" — which is true, but only names the room, not the fault.
The genuinely disorienting version is the one where the token is fine. It authenticates, GET /user returns 200, and everything still fails, because the login coming back is a service account nobody remembered was in that variable. A valid credential for the wrong identity produces 404s and 403s all over an integration and never once says the word credentials.
Why it happens
An invalid credential is rejected before authorization is considered. This is why the root endpoint, which any anonymous caller can read, answers 401 when you attach a broken Authorization header. Presenting a credential GitHub cannot accept is worse than presenting none at all, and that asymmetry is the most useful fact in this note.
The two messages are the discriminator. Bad credentials is only ever produced when a value was received and refused. Requires authentication is only produced when nothing was received on an endpoint that required something. If you are getting the second one while your code is definitely setting a header, the header is being lost between your process and GitHub, and the token is innocent.
A 401 is not always from GitHub. Corporate proxies, API gateways and mitm TLS appliances all answer 401, and their responses do not carry x-github-request-id or GitHub's documentation_url. Checking for that furniture takes one line and saves you from re-minting a token that never left the building.
A 200 from GET /user is not the end of the check. It proves the credential is valid; it says nothing about whose it is. The login in that response is the only cheap assertion that catches a token from the wrong account, and it is the assertion almost nobody writes.
The tier check is a different question. If the header is being stripped, the follow-up is how many requests an hour you now get and whether anything is quietly succeeding at 60 an hour. That is its own note, and this script hands off to it rather than duplicating it.
The fix, as a flow
Three requests, two of them to an endpoint that needs no credential at all. The pair with and without the header is what turns a guess into proof: GitHub only says Bad credentials about a value it actually received, and it says something else entirely about a value that never arrived.
How to fix it
Ask an endpoint that needs no credential, with the header attached
GET / is the REST root: an anonymous caller reads it fine. Attach your Authorization header to it. If it answers 401 Bad credentials, GitHub received your value and rejected it, and you have eliminated every theory involving permissions, scopes, repositories and organizations in a single request.
Run the same request with no header at all as a control
If the control also fails, the problem is not your credential: something is refusing you before authentication is even relevant — an IP allow list, a proxy, a captive network. A control that succeeds while the credentialled request fails is the clean, publishable result.
Confirm GitHub is the thing answering
Look for x-github-request-id on the 401. GitHub puts it on every response. A 401 without it is very likely an intermediary, and the repair is a network conversation rather than a token rotation. Note the request id either way: it is the identifier support will ask for.
Read the message, not just the status
Requires authentication on GET /user while your code is setting a header means the header is not arriving. Common causes are a redirect that dropped it, a client library that only applies auth to configured hosts, and a proxy that strips unknown headers. Rotating the token cannot fix any of them.
Assert the login, not just the status code
Once GET /user returns 200, compare the login against the account you expect and fail loudly on a mismatch. Do this at startup, before the integration does any real work. It is three lines, it costs one free request, and it is the only check that catches a perfectly valid credential belonging to the wrong identity.
How to check it worked
The report should name a layer, not a feeling. Two of the states below are repaired by re-minting a token and three of them are not, which is exactly why the check exists.
python3 github_401_provenance.py --expect-login acme-ci-bot
# public endpoint with the header: 401 bad credentials
# public endpoint without any header: 200
# credential-rejected: GitHub parsed the value in GITHUB_TOKEN and refused it.
# An endpoint that needs no credential at all answered 200 without the
# header and 401 with it, so the value is the thing being rejected.
The full code
Three GETs and no state. Two of them hit the REST root, once with the header and once without, and the third is GET /user. None of them consume anything worth counting and none of them need a scope. The credential is read from the environment and never printed, not even a prefix: this script does not inspect the secret's text at all, because the whole argument is about what GitHub did with it. Everything that produces a verdict is a pure function over three small dicts.
"""Say which layer produced a 401, using two messages and one control request.
Read only. Three GETs: the REST root with the credential attached, the REST root
with no credential at all, and GET /user. None of them writes and none of them
needs a scope.
GitHub returns two different 401 messages. "Bad credentials" means a value was
received and refused. "Requires authentication" means nothing was received. The
distance between those two sentences is the whole diagnosis, and the control
request is what makes the first one provable rather than assumed.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_401_provenance")
API = "https://api.github.com"
UA = "github-401-provenance/1.0"
# The REST root. Any anonymous caller may read it, which is precisely why
# attaching a broken credential to it is such a clean experiment.
PUBLIC_PATH = "/"
BAD_CREDENTIALS = "bad credentials"
REQUIRES_AUTH = "requires authentication"
# Response furniture GitHub puts on everything it answers. An appliance in the
# middle that decides to return 401 will not have any of it.
GITHUB_FURNITURE = ("x-github-request-id", "x-github-media-type",
"x-github-api-version-selected")
def message_of(body):
"""The message GitHub put in the body, folded to lower case. Pure.
None for anything that is not a JSON object with a non-empty message, so a
truncated body or an HTML error page from a proxy does not get read as a
GitHub verdict.
"""
if not isinstance(body, dict):
return None
value = body.get("message")
if not isinstance(value, str) or not value.strip():
return None
return value.strip().lower()
def from_github(headers):
"""Whether GitHub itself answered, rather than something in front of it. Pure.
Returns (bool, which-header-said-so). The negative result is the valuable
one: a 401 with none of this furniture is very likely a proxy, and no amount
of rotating credentials will change its mind.
"""
lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
for name in GITHUB_FURNITURE:
if lowered.get(name):
return (True, name)
if "github" in str(lowered.get("server", "")).lower():
return (True, "server")
return (False, None)
def rung(status, message):
"""Reduce one probe to a symbol. Pure.
The two 401s get different symbols, because they are different findings, and
a 401 with neither message gets a third symbol rather than being forced into
whichever of the two the code happened to check first.
"""
try:
status = int(status)
except (TypeError, ValueError):
return "error"
if status == 0:
return "error"
if 200 <= status < 300:
return "ok"
if status == 401:
if message == BAD_CREDENTIALS:
return "rejected"
if message == REQUIRES_AUTH:
return "anonymous"
return "unlabelled-401"
if status == 403:
return "forbidden"
return "http-%d" % status
def diagnose(public_with, public_without, user, expected_login=None):
"""Name the layer that produced the 401. Pure.
Each probe is a dict of {"status", "message", "github", "login"}. Nothing
here looks at the credential's text: the argument is entirely about what
GitHub did with it.
"""
def symbol(probe):
probe = probe or {}
return rung(probe.get("status"), probe.get("message"))
with_header = symbol(public_with)
without_header = symbol(public_without)
identity = symbol(user)
if with_header in ("rejected", "anonymous", "unlabelled-401") \
and not (public_with or {}).get("github"):
return ("not-github",
"the 401 carried none of GitHub's response furniture: no "
"request id, no media type, no GitHub server header. Something "
"between this process and api.github.com answered, and it is "
"not looking at your credential. Re-minting will not help.")
if without_header == "error":
return ("no-baseline",
"the control request, which carries no credential at all, could "
"not be made. Without it nothing below can be separated from a "
"network fault.")
if without_header != "ok":
return ("anonymous-refused",
"the control request carries no credential and was still "
"refused (%s). Whatever is producing this is not reading your "
"token: look at IP allow lists, egress proxies and the network "
"before you look at the credential." % without_header)
if with_header == "rejected":
return ("credential-rejected",
"GitHub parsed the value and refused it. An endpoint that needs "
"no credential at all answered 200 without the header and 401 "
"with it, so the value being sent is the thing being rejected: "
"expired, revoked, truncated, or from an account that no longer "
"exists. That is a re-mint, not a network change.")
if identity == "anonymous":
return ("header-not-arriving",
"GET /user answered 401 Requires authentication, which is the "
"message for a request that carried nothing. The header is "
"being lost between here and GitHub: a redirect that dropped "
"it, a client that only applies auth to configured hosts, or a "
"proxy that strips what it does not recognise.")
if identity == "rejected" and with_header == "ok":
return ("path-dependent",
"the public endpoint accepted or ignored the same credential "
"that GET /user refused. Two requests from the same process are "
"not arriving as the same request, which points at something "
"rewriting them in between.")
if identity == "rejected":
return ("credential-rejected",
"GET /user answered 401 Bad credentials, so the value was "
"received and refused.")
if identity == "forbidden":
return ("authenticated-but-forbidden",
"the credential is valid and GET /user answered 403. That is "
"not a bad credential: look at SSO authorisation, IP allow "
"lists and organization policy.")
if identity == "ok":
login = (user or {}).get("login")
if expected_login and str(login or "").lower() != str(expected_login).lower():
return ("wrong-account",
"the credential is valid and belongs to %r, not to the "
"expected %r. A valid token for the wrong identity produces "
"404s and 403s all over an integration and never once says "
"the word credentials." % (login, expected_login))
return ("credential-valid",
"the credential authenticates as %r. Whatever is returning 401 "
"is not this credential on this host, so look at the other "
"variable, the other host, or the other process."
% (login or "an unnamed account"))
return ("unclear",
"the three probes do not agree: root with header %s, root without "
"header %s, /user %s. Report the request id from the failing "
"response rather than guessing."
% (with_header, without_header, identity))
def probe(path, token=None):
"""One GET, reduced to the four things the diagnosis needs."""
headers = {"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA}
if token:
headers["Authorization"] = "Bearer " + token
try:
response = requests.get(API + path, headers=headers, timeout=30)
except requests.RequestException as exc:
log.error("GET %s failed: %s", path, exc)
return {"status": 0, "message": None, "github": False, "login": None,
"request_id": None}
try:
body = response.json()
except ValueError:
body = None
is_github, which = from_github(response.headers)
return {"status": response.status_code,
"message": message_of(body),
"github": is_github,
"github_signal": which,
"login": (body or {}).get("login") if isinstance(body, dict) else None,
"request_id": response.headers.get("x-github-request-id")}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--env", default="GITHUB_TOKEN",
help="environment variable holding the credential")
parser.add_argument("--expect-login",
help="the account this credential is supposed to be")
args = parser.parse_args()
token = os.environ.get(args.env)
if not token:
log.error("%s is not set, so there is no credential to account for. "
"That is a different note: every request goes out anonymous.",
args.env)
return 2
public_with = probe(PUBLIC_PATH, token)
public_without = probe(PUBLIC_PATH, None)
user = probe("/user", token)
log.info("public endpoint with the header: %d %s",
public_with["status"], public_with["message"] or "")
log.info("public endpoint without any header: %d %s",
public_without["status"], public_without["message"] or "")
log.info("GET /user: %d %s",
user["status"], user["message"] or "")
if not public_with["github"]:
log.warning("the credentialled response carried none of GitHub's "
"response furniture")
for name, result in (("root", public_with), ("user", user)):
if result["request_id"]:
log.info("%s request id %s", name, result["request_id"])
state, detail = diagnose(public_with, public_without, user, args.expect_login)
log.info("%s: %s", state, detail)
if state == "credential-rejected":
log.info("repair: re-mint the credential, store it with no surrounding "
"whitespace or quotes, and assert at startup that GET /user "
"returns 200 before doing any real work.")
if state == "header-not-arriving":
log.info("repair: log the outgoing request headers at the transport "
"layer and check the tier as well; a stripped header means "
"60 requests an hour, not zero.")
if state == "wrong-account":
log.info("repair: assert the expected login at startup. It is three "
"lines and it costs one free request.")
print(json.dumps({"state": state,
"public_with": public_with["status"],
"public_without": public_without["status"],
"user": user["status"]}, indent=2))
return 1 if state not in ("credential-valid",) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say which layer produced a 401, using two messages and one control request.
*
* Read only. Three GETs: the REST root with the credential, the REST root with
* no credential at all, and GET /user.
*
* "Bad credentials" means a value was received and refused. "Requires
* authentication" means nothing was received. The control request is what makes
* the first of those provable rather than assumed.
*/
const API = 'https://api.github.com';
const UA = 'github-401-provenance/1.0';
// The REST root: readable by any anonymous caller, which is what makes it a
// clean place to attach a broken credential.
export const PUBLIC_PATH = '/';
export const BAD_CREDENTIALS = 'bad credentials';
export const REQUIRES_AUTH = 'requires authentication';
export const GITHUB_FURNITURE = [
'x-github-request-id', 'x-github-media-type', 'x-github-api-version-selected',
];
/** The message GitHub put in the body, folded to lower case. Pure. */
export function messageOf(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const value = body.message;
if (typeof value !== 'string' || !value.trim()) return null;
return value.trim().toLowerCase();
}
/** Whether GitHub itself answered, rather than something in front of it. Pure. */
export function fromGithub(headers) {
const lowered = {};
for (const [k, v] of Object.entries(headers ?? {})) lowered[String(k).toLowerCase()] = v;
for (const name of GITHUB_FURNITURE) {
if (lowered[name]) return [true, name];
}
if (String(lowered.server ?? '').toLowerCase().includes('github')) return [true, 'server'];
return [false, null];
}
/** Reduce one probe to a symbol. Pure. The two 401s get different symbols. */
export function rung(status, message) {
const code = Number.parseInt(status, 10);
if (!Number.isFinite(code) || code === 0) return 'error';
if (code >= 200 && code < 300) return 'ok';
if (code === 401) {
if (message === BAD_CREDENTIALS) return 'rejected';
if (message === REQUIRES_AUTH) return 'anonymous';
return 'unlabelled-401';
}
if (code === 403) return 'forbidden';
return `http-${code}`;
}
/** Name the layer that produced the 401. Pure. */
export function diagnose(publicWith, publicWithout, user, expectedLogin = null) {
const symbol = (probe) => rung((probe ?? {}).status, (probe ?? {}).message);
const withHeader = symbol(publicWith);
const withoutHeader = symbol(publicWithout);
const identity = symbol(user);
if (['rejected', 'anonymous', 'unlabelled-401'].includes(withHeader)
&& !(publicWith ?? {}).github) {
return ['not-github',
"the 401 carried none of GitHub's response furniture: no request id, no " +
'media type, no GitHub server header. Something between this process and ' +
'api.github.com answered, and it is not looking at your credential. ' +
'Re-minting will not help.'];
}
if (withoutHeader === 'error') {
return ['no-baseline',
'the control request, which carries no credential at all, could not be ' +
'made. Without it nothing below can be separated from a network fault.'];
}
if (withoutHeader !== 'ok') {
return ['anonymous-refused',
`the control request carries no credential and was still refused (${withoutHeader}). ` +
'Whatever is producing this is not reading your token: look at IP allow ' +
'lists, egress proxies and the network before you look at the credential.'];
}
if (withHeader === 'rejected') {
return ['credential-rejected',
'GitHub parsed the value and refused it. An endpoint that needs no ' +
'credential at all answered 200 without the header and 401 with it, so ' +
'the value being sent is the thing being rejected: expired, revoked, ' +
'truncated, or from an account that no longer exists. That is a re-mint, ' +
'not a network change.'];
}
if (identity === 'anonymous') {
return ['header-not-arriving',
'GET /user answered 401 Requires authentication, which is the message for ' +
'a request that carried nothing. The header is being lost between here ' +
'and GitHub: a redirect that dropped it, a client that only applies auth ' +
'to configured hosts, or a proxy that strips what it does not recognise.'];
}
if (identity === 'rejected' && withHeader === 'ok') {
return ['path-dependent',
'the public endpoint accepted or ignored the same credential that GET ' +
'/user refused. Two requests from the same process are not arriving as ' +
'the same request, which points at something rewriting them in between.'];
}
if (identity === 'rejected') {
return ['credential-rejected',
'GET /user answered 401 Bad credentials, so the value was received and refused.'];
}
if (identity === 'forbidden') {
return ['authenticated-but-forbidden',
'the credential is valid and GET /user answered 403. That is not a bad ' +
'credential: look at SSO authorisation, IP allow lists and organization policy.'];
}
if (identity === 'ok') {
const login = (user ?? {}).login;
if (expectedLogin && String(login ?? '').toLowerCase() !== String(expectedLogin).toLowerCase()) {
return ['wrong-account',
`the credential is valid and belongs to '${login}', not to the expected ` +
`'${expectedLogin}'. A valid token for the wrong identity produces 404s ` +
'and 403s all over an integration and never once says the word credentials.'];
}
return ['credential-valid',
`the credential authenticates as '${login ?? 'an unnamed account'}'. ` +
'Whatever is returning 401 is not this credential on this host, so look ' +
'at the other variable, the other host, or the other process.'];
}
return ['unclear',
`the three probes do not agree: root with header ${withHeader}, root without ` +
`header ${withoutHeader}, /user ${identity}. Report the request id from the ` +
'failing response rather than guessing.'];
}
async function probe(path, token = null) {
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
if (token) headers.Authorization = `Bearer ${token}`;
let res;
try {
res = await fetch(API + path, { headers });
} catch (err) {
console.error(`GET ${path} failed: ${err.message}`);
return { status: 0, message: null, github: false, login: null, request_id: null };
}
let body = null;
try { body = await res.json(); } catch { body = null; }
const raw = {};
for (const [k, v] of res.headers.entries()) raw[k.toLowerCase()] = v;
const [isGithub, which] = fromGithub(raw);
return {
status: res.status,
message: messageOf(body),
github: isGithub,
github_signal: which,
login: body && typeof body === 'object' ? body.login ?? null : null,
request_id: raw['x-github-request-id'] ?? null,
};
}
async function main() {
const envName = process.env.GITHUB_TOKEN_ENV || 'GITHUB_TOKEN';
const token = process.env[envName];
if (!token) {
console.error(`${envName} is not set, so there is no credential to account ` +
'for. That is a different note: every request goes out anonymous.');
process.exitCode = 2;
return;
}
const expectedLogin = process.argv[2] ?? null;
const publicWith = await probe(PUBLIC_PATH, token);
const publicWithout = await probe(PUBLIC_PATH, null);
const user = await probe('/user', token);
console.log(`public endpoint with the header: ${publicWith.status} ${publicWith.message ?? ''}`);
console.log(`public endpoint without any header: ${publicWithout.status} ${publicWithout.message ?? ''}`);
console.log(`GET /user: ${user.status} ${user.message ?? ''}`);
if (!publicWith.github) {
console.warn("the credentialled response carried none of GitHub's response furniture");
}
for (const [name, result] of [['root', publicWith], ['user', user]]) {
if (result.request_id) console.log(`${name} request id ${result.request_id}`);
}
const [state, detail] = diagnose(publicWith, publicWithout, user, expectedLogin);
console.log(`${state}: ${detail}`);
if (state === 'credential-rejected') {
console.log('repair: re-mint the credential, store it with no surrounding ' +
'whitespace or quotes, and assert at startup that GET /user returns 200.');
}
if (state === 'header-not-arriving') {
console.log('repair: log the outgoing request headers at the transport layer ' +
'and check the tier as well; a stripped header means 60 an hour, not zero.');
}
if (state === 'wrong-account') {
console.log('repair: assert the expected login at startup. It is three lines ' +
'and it costs one free request.');
}
console.log(JSON.stringify({
state,
public_with: publicWith.status,
public_without: publicWithout.status,
user: user.status,
}, null, 2));
process.exitCode = state === 'credential-valid' ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err); process.exitCode = 2; });
}
Add a test
The classifier takes three small dicts, which means every branch is a two-line test and none of them needs a broken token to exist. The ones worth writing down are the near misses: a 401 with no request id must not be reported as a bad credential, a 401 on the control request must stop the diagnosis rather than continue it, and a 200 from GET /user under an unexpected login must be a failure rather than a pass. The unlabelled 401 gets its own symbol so a future message change degrades into unclear instead of into a confident wrong answer.
from github_401_provenance import diagnose, from_github, message_of, rung
def gh(status, message=None, login=None, github=True):
return {"status": status, "message": message, "login": login, "github": github}
def test_the_two_messages_get_different_symbols():
assert rung(401, "bad credentials") == "rejected"
assert rung(401, "requires authentication") == "anonymous"
def test_a_401_with_neither_message_is_not_forced_into_one():
assert rung(401, None) == "unlabelled-401"
assert rung(401, "something new") == "unlabelled-401"
def test_the_ordinary_statuses_reduce_predictably():
assert rung(200, None) == "ok"
assert rung(204, None) == "ok"
assert rung(403, "forbidden") == "forbidden"
assert rung(404, None) == "http-404"
assert rung(0, None) == "error"
assert rung(None, None) == "error"
def test_the_message_is_only_read_from_a_json_object():
assert message_of({"message": " Bad Credentials "}) == "bad credentials"
assert message_of({"message": ""}) is None
assert message_of("<html>401</html>") is None
assert message_of(None) is None
def test_githubs_furniture_is_recognised_whatever_the_header_case():
assert from_github({"X-GitHub-Request-Id": "ABC:123"}) == (True, "x-github-request-id")
assert from_github({"Server": "github.com"})[0] is True
assert from_github({"server": "squid/5.7"}) == (False, None)
assert from_github({}) == (False, None)
def test_a_401_without_githubs_furniture_is_an_intermediary():
state, detail = diagnose(gh(401, "bad credentials", github=False),
gh(200), gh(401, "bad credentials", github=False))
assert state == "not-github"
assert "Re-minting will not help" in detail
def test_a_refused_control_stops_the_diagnosis():
state, _ = diagnose(gh(401, "bad credentials"), gh(403, "forbidden"), gh(401))
assert state == "anonymous-refused"
def test_a_control_that_could_not_be_made_is_its_own_state():
assert diagnose(gh(401, "bad credentials"), gh(0), gh(401))[0] == "no-baseline"
def test_rejected_on_a_public_endpoint_is_the_credential():
state, detail = diagnose(gh(401, "bad credentials"), gh(200),
gh(401, "bad credentials"))
assert state == "credential-rejected"
assert "200 without the header and 401 with it" in detail
def test_requires_authentication_means_the_header_never_arrived():
state, detail = diagnose(gh(200), gh(200), gh(401, "requires authentication"))
assert state == "header-not-arriving"
assert "carried nothing" in detail
def test_a_credential_accepted_on_one_path_and_refused_on_another():
state, _ = diagnose(gh(200), gh(200), gh(401, "bad credentials"))
assert state == "path-dependent"
def test_a_valid_credential_for_the_wrong_account_is_a_failure():
state, detail = diagnose(gh(200), gh(200), gh(200, login="someone-else"),
expected_login="acme-ci-bot")
assert state == "wrong-account"
assert "someone-else" in detail
def test_the_login_comparison_ignores_case():
assert diagnose(gh(200), gh(200), gh(200, login="Acme-CI-Bot"),
expected_login="acme-ci-bot")[0] == "credential-valid"
def test_a_403_on_user_is_not_a_bad_credential():
state, detail = diagnose(gh(200), gh(200), gh(403, "forbidden"))
assert state == "authenticated-but-forbidden"
assert "SSO" in detail
def test_a_working_credential_sends_you_to_look_elsewhere():
state, detail = diagnose(gh(200), gh(200), gh(200, login="acme-ci-bot"))
assert state == "credential-valid"
assert "acme-ci-bot" in detail
def test_probes_that_disagree_are_reported_as_unclear_rather_than_guessed():
assert diagnose(gh(500), gh(200), gh(500))[0] == "unclear"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
diagnose, fromGithub, messageOf, rung,
} from './github-401-provenance.mjs';
const gh = (status, message = null, login = null, github = true) =>
({ status, message, login, github });
test('the two messages get different symbols', () => {
assert.equal(rung(401, 'bad credentials'), 'rejected');
assert.equal(rung(401, 'requires authentication'), 'anonymous');
});
test('a 401 with neither message is not forced into one', () => {
assert.equal(rung(401, null), 'unlabelled-401');
assert.equal(rung(401, 'something new'), 'unlabelled-401');
});
test('the ordinary statuses reduce predictably', () => {
assert.equal(rung(200, null), 'ok');
assert.equal(rung(204, null), 'ok');
assert.equal(rung(403, 'forbidden'), 'forbidden');
assert.equal(rung(404, null), 'http-404');
assert.equal(rung(0, null), 'error');
assert.equal(rung(null, null), 'error');
});
test('the message is only read from a json object', () => {
assert.equal(messageOf({ message: ' Bad Credentials ' }), 'bad credentials');
assert.equal(messageOf({ message: '' }), null);
assert.equal(messageOf('<html>401</html>'), null);
assert.equal(messageOf(null), null);
});
test("github's furniture is recognised whatever the header case", () => {
assert.deepEqual(fromGithub({ 'X-GitHub-Request-Id': 'ABC:123' }),
[true, 'x-github-request-id']);
assert.equal(fromGithub({ Server: 'github.com' })[0], true);
assert.deepEqual(fromGithub({ server: 'squid/5.7' }), [false, null]);
assert.deepEqual(fromGithub({}), [false, null]);
});
test('a 401 without github furniture is an intermediary', () => {
const [state, detail] = diagnose(gh(401, 'bad credentials', null, false),
gh(200), gh(401, 'bad credentials', null, false));
assert.equal(state, 'not-github');
assert.match(detail, /Re-minting will not help/);
});
test('a refused control stops the diagnosis', () => {
const [state] = diagnose(gh(401, 'bad credentials'), gh(403, 'forbidden'), gh(401));
assert.equal(state, 'anonymous-refused');
});
test('a control that could not be made is its own state', () => {
assert.equal(diagnose(gh(401, 'bad credentials'), gh(0), gh(401))[0], 'no-baseline');
});
test('rejected on a public endpoint is the credential', () => {
const [state, detail] = diagnose(gh(401, 'bad credentials'), gh(200),
gh(401, 'bad credentials'));
assert.equal(state, 'credential-rejected');
assert.match(detail, /200 without the header and 401 with it/);
});
test('requires authentication means the header never arrived', () => {
const [state, detail] = diagnose(gh(200), gh(200), gh(401, 'requires authentication'));
assert.equal(state, 'header-not-arriving');
assert.match(detail, /carried nothing/);
});
test('a credential accepted on one path and refused on another', () => {
const [state] = diagnose(gh(200), gh(200), gh(401, 'bad credentials'));
assert.equal(state, 'path-dependent');
});
test('a valid credential for the wrong account is a failure', () => {
const [state, detail] = diagnose(gh(200), gh(200), gh(200, null, 'someone-else'),
'acme-ci-bot');
assert.equal(state, 'wrong-account');
assert.match(detail, /someone-else/);
});
test('the login comparison ignores case', () => {
assert.equal(diagnose(gh(200), gh(200), gh(200, null, 'Acme-CI-Bot'),
'acme-ci-bot')[0], 'credential-valid');
});
test('a 403 on user is not a bad credential', () => {
const [state, detail] = diagnose(gh(200), gh(200), gh(403, 'forbidden'));
assert.equal(state, 'authenticated-but-forbidden');
assert.match(detail, /SSO/);
});
test('a working credential sends you to look elsewhere', () => {
const [state, detail] = diagnose(gh(200), gh(200), gh(200, null, 'acme-ci-bot'));
assert.equal(state, 'credential-valid');
assert.match(detail, /acme-ci-bot/);
});
test('probes that disagree are reported as unclear rather than guessed', () => {
assert.equal(diagnose(gh(500), gh(200), gh(500))[0], 'unclear');
});
FAQ
Is Bad credentials ever a permissions problem?
No, and that is what makes it worth separating from everything else. Permissions and scopes produce 403 with a different message, usually naming the resource or the integration. A 401 means the credential itself was not accepted, so no amount of adding scopes, accepting an App permission upgrade or authorising SSO will change the answer. If you are getting 401 and reaching for the permissions page, you are in the wrong place.
Why does a public endpoint return 401 when I attach a bad token?
Because presenting a credential is a claim, and GitHub validates the claim before it decides what you are allowed to see. An anonymous request makes no claim, so it is served. A request carrying something GitHub cannot accept is refused outright, even for content it would have handed to a stranger. That asymmetry is inconvenient in production and extremely useful in a diagnosis.
My token works in curl but not in the script. What is different?
Usually the value, not the code. A token pasted into a terminal is exactly what you pasted; a token that has been through a file, a secrets store, an environment file and a shell may have picked up a newline, surrounding quotes or a truncation. The control request tells you which world you are in: if GitHub says Bad credentials, the value reaching it differs from the one you pasted.
The script says header-not-arriving. Where do I look first?
At redirects and at your HTTP client's host rules. Most clients deliberately drop the Authorization header when a redirect crosses hosts, which is correct behaviour and surprising the first time. After that, look at any egress proxy: stripping unrecognised headers is a common default. Log the headers as they leave the process rather than as you set them.
Should the login assertion run on every request?
Once at startup is enough, and once is the point. The check is cheap, it uses an endpoint that needs no scope, and it converts a class of failure that shows up hours later as scattered 404s into a single clear line at the moment the process starts. Anything that fails should fail while someone is still watching.
Related field notes
- A classic PAT passed its expiry date
- Requests go out anonymous at 60 an hour
- A 404 that is really a permissions problem
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.
- Troubleshooting the REST API — GitHub Docs
- Authenticating to the REST API — GitHub Docs
- Users — GitHub REST API
- Best practices for using the REST API — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.