Diagnostic GitHub API
the client still sends a username and password to the API
401 {"message": "Support for password authentication was removed. Please use a personal access token instead."}. The reflex is to go and mint a token, which is right, and then to paste it where the password was, which is half right, and then to be surprised when a different call still fails. The credential was never the problem. The envelope was.
GitHub removed password authentication from the API. Authorization: Basic base64(user:password) is refused everywhere, permanently, and no password will ever work again. Authorization: Bearer TOKEN is the replacement, Authorization: token TOKEN is still accepted, and Basic base64(user:token) works on some paths and is not worth keeping.
Which of those you are sending is decidable offline, from the header alone, and the script below decides it before making any request. If the configured header is a username and a password, it refuses to send it: pushing an account password through one more proxy in order to be told it will not work is not a diagnostic, it is another copy of the password. It reports the shape, prints the replacement header, and separately proves your token is fine with a single GET /user.
The problem in plain words
This one has an unusually long tail because it lives in places nobody greps. A PowerShell Invoke-WebRequest -Credential from a 2016 blog post. A ~/.netrc entry. A CI job that reuses the same credentials as a Git remote. A library whose constructor still takes (username, password) and quietly builds a Basic header out of whatever it is handed. None of those look like an Authorization header at the call site, which is exactly why they survive the migration.
The error message is also, unusually, helpful enough to cause a second problem. It says "use a personal access token instead", so people put the token where the password was and leave the username in place. That produces Basic base64(user:token), which works on enough paths to look like a fix and is a shape GitHub has been steering away from for years. The change gets marked done and the retired mechanism stays in the code.
And the failure is total rather than partial, which throws people off the scent. Almost every other credential problem in this section is one endpoint, one org, one repository. This one is every request, immediately, which reads like an outage. The team goes looking for a platform incident instead of for the four characters Basic in a config file.
Why it happens
It is a mechanism removal, not a policy toggle. There is no organization setting, no plan tier and no header that re-enables it. Password authentication for the API is gone, so this is not something to negotiate with an administrator; it is something to change in the client.
The three accepted shapes are not equivalent. Bearer TOKEN is the documented form and the one to use. token TOKEN is the older GitHub-specific form and still accepted. Basic base64(user:token) survives on parts of the API and on Git over HTTPS, which is why it lingers, but it carries a username that means nothing and encodes the secret in a way that invites it into places a header would not go.
The distinction is visible without a request. Base64 is an encoding, not encryption: the client can decode its own header, look at the half after the first colon, and see whether it is a token or a password. That check costs nothing and can run at startup, which is where it belongs.
Sending it to find out is the wrong move. The documented detection is to make the call and read the message, and for a token that is fine. For an account password it means transmitting a live password through your proxy, your egress logs and your terminal scrollback to learn something the header already told you. A diagnostic that creates a new copy of the secret is a bad diagnostic.
A 401 with a different message is a different note. Bad credentials means the token itself is wrong, revoked or expired; this message means the mechanism is retired. Reading which of the two you got is the whole triage, and it takes one line.
The fix, as a flow
The useful half of this script makes no request. Base64 is an encoding, so the client can decode its own header and read the half after the colon on its own machine. Only the replacement goes on the wire, and only with a credential from the environment, which separates a good token in a retired envelope from a bad token in the right one.
How to fix it
Capture the header the client actually sends
Not what the config says, what goes on the wire. Most HTTP clients will log the request headers behind a debug flag, and library wrappers that take a username and password build the header for you without ever showing it. Put the value in an environment variable and hand it to the script; nothing about it needs to be pasted into a terminal.
Decode it locally and look at the half after the colon
For a Basic header, that half is either a token — ghp_, gho_, ghs_, github_pat_ or forty hex characters — or it is a password. This is the entire diagnosis and it happens on your machine, with no request and no third copy of the secret.
Do not send a password to confirm it
You already know the answer, and the confirmation costs you a password in a proxy log. The script declines this one deliberately. If you genuinely need the response for a ticket, reproduce it with a throwaway account, not with the credential that is in production.
Prove the token separately
One GET /user with Authorization: Bearer and the token from the environment. A 200 with your login says the credential is good and the mechanism was the only thing wrong, which is the sentence that closes the ticket.
Sweep the call sites, not just the client
curl -u, --user, Invoke-WebRequest -Credential, ~/.netrc, and any library constructor taking two strings. Each one builds the retired header somewhere you will not find by searching for the word Authorization. Replace them all, then delete the username field entirely rather than leaving it set to something harmless.
How to check it worked
Point the check at the corrected header. The scheme should classify as bearer and the probe should return your login.
GITHUB_AUTH_HEADER="Bearer $GITHUB_TOKEN" python3 github_auth_scheme_check.py
# scheme: bearer, secret 40 char(s), no username
# probe: GET /user returned 200 as octo-bot
# ok: the documented scheme, and the credential behind it authenticates
The full code
The interesting property of this script is a request it does not make. Classification is offline: decode the header, split on the first colon, decide whether the second half is a token or a password. Only the Bearer probe goes on the wire, and only with a credential read from the environment. The secret never appears in the output, in the log line, or in the JSON at the end — the report carries the scheme, the length and whether a username was present, which is everything the repair needs.
"""Decide which authentication mechanism a GitHub client is using, offline.
Read only, and one request: GET /user with a Bearer header built from the
environment. The script deliberately never transmits a username and password,
even to reproduce the documented 401. Sending a live password to be told it
will not work costs you a password in a proxy log and buys you nothing the
header did not already say.
Nothing here prints the secret. The report carries the scheme, the length and
whether a username was present.
"""
import argparse
import base64
import binascii
import json
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_auth_scheme_check")
API = "https://api.github.com"
UA = "github-auth-scheme-check/1.0"
# Prefixes GitHub issues its tokens with. The list is used only to answer "is
# the half after the colon a token or a password", so a new prefix appearing
# here later would make the check more precise rather than change its meaning.
TOKEN_PREFIXES = ("ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_")
LEGACY_HEX = re.compile(r"^[0-9a-f]{40}$")
# The message that means the mechanism is retired, as opposed to a 401 whose
# message is about the credential itself being wrong.
REMOVED = "support for password authentication was removed"
# Call sites that build the retired header without the word Authorization ever
# appearing. Matched by shape; the matched text is never echoed.
CALL_SITES = (
("curl -u", re.compile(r"\bcurl\b[^\n]*?\s(-u|--user)\s")),
("Invoke-WebRequest -Credential", re.compile(r"-Credential\b")),
("netrc entry", re.compile(r"^\s*machine\s+[\w.]*github", re.I)),
("two-string client constructor", re.compile(r"\b(username|user)\s*=\s*[^,\n]+,\s*password\s*=")),
)
def looks_like_token(secret):
"""Is this string shaped like a GitHub token rather than a password? Pure."""
value = str(secret or "")
if value.startswith(TOKEN_PREFIXES):
return True
return bool(LEGACY_HEX.match(value))
def parse_auth_header(value):
"""Describe an Authorization header without revealing what is in it. Pure.
Returns the scheme, whether a username was present, the length of the
secret and whether the secret is token-shaped. The secret itself is never
part of the return value, so no caller can accidentally log it.
"""
raw = (value or "").strip()
if not raw:
return {"scheme": None, "username_present": False, "secret_length": 0,
"token_shaped": False, "decoded": True}
parts = raw.split(None, 1)
scheme = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
if scheme != "basic":
return {"scheme": scheme, "username_present": False,
"secret_length": len(rest), "token_shaped": looks_like_token(rest),
"decoded": True}
try:
decoded = base64.b64decode(rest, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError, ValueError):
return {"scheme": "basic", "username_present": False, "secret_length": 0,
"token_shaped": False, "decoded": False}
user, sep, secret = decoded.partition(":")
return {"scheme": "basic", "username_present": bool(user) or bool(sep),
"secret_length": len(secret), "token_shaped": looks_like_token(secret),
"decoded": True}
def classify(parsed):
"""Name the mechanism from a parsed header. Pure."""
scheme = (parsed or {}).get("scheme")
if scheme is None:
return "no-credential"
if scheme == "basic":
if not parsed.get("decoded"):
return "undecodable-basic"
return "token-basic" if parsed.get("token_shaped") else "password-basic"
if scheme == "bearer":
return "bearer"
if scheme == "token":
return "token-scheme"
return "unknown-scheme"
def password_removed(body):
"""Does this response body carry the retired-mechanism message? Pure."""
if isinstance(body, dict):
text = str(body.get("message", ""))
else:
text = str(body or "")
return REMOVED in " ".join(text.lower().split())
def replacement_header():
"""The one line that replaces every retired form. Pure."""
return "Authorization: Bearer $GITHUB_TOKEN"
def scan_snippet(text):
"""Find call sites that build a username-and-password header. Pure.
Reports the line number and the shape, never the line. A snippet audit that
quotes the matching line back at you puts the credential in the report.
"""
findings = []
for number, line in enumerate(str(text or "").splitlines(), start=1):
for label, pattern in CALL_SITES:
if pattern.search(line):
findings.append({"line": number, "form": label})
return findings
def verdict(kind, probe_status, probe_body):
"""Turn the classification and the Bearer probe into a finding. Pure."""
if kind == "password-basic":
return ("password-basic",
"the header is a username and a password. That mechanism was "
"removed from the API and no password will ever be accepted "
"again. Nothing was sent: the shape is the answer, and "
"transmitting it would only add a copy of the password to your "
"proxy log.")
if kind == "token-basic":
return ("token-basic",
"the header is a username and a token. That still works on much "
"of the API, which is why it survives, but the username is "
"meaningless and the form is on the way out. Replace it.")
if kind == "undecodable-basic":
return ("undecodable-basic",
"the header says Basic but the payload is not valid base64, so "
"something is double-encoding or truncating it before it goes "
"out. GitHub will read this as no credential at all.")
if kind == "no-credential":
return ("no-credential",
"no Authorization header was configured, so requests go out "
"anonymous rather than refused, and quietly get the 60 an hour "
"tier instead of an error.")
if kind == "unknown-scheme":
return ("unknown-scheme",
"the scheme is neither Basic, Bearer nor token, so GitHub will "
"ignore it and treat the request as unauthenticated.")
if probe_status == 200:
return ("ok", "the documented scheme, and the credential behind it "
"authenticates.")
if password_removed(probe_body):
return ("password-removed-message",
"the scheme looks right but GitHub still answered with the "
"retired-mechanism message, so something downstream is "
"rewriting the header into Basic before it leaves.")
if probe_status == 401:
return ("credential-rejected",
"the mechanism is correct and the credential is not. That is a "
"different problem from this one: the token is wrong, revoked "
"or expired rather than badly wrapped.")
return ("probe-inconclusive",
"the scheme is correct; the probe returned %s rather than 200, so "
"judge the credential separately." % probe_status)
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
url = API + path if path.startswith("/") else path
r = session.get(url, timeout=30)
try:
return r.status_code, r.json()
except ValueError:
return r.status_code, None
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--snippet-file",
help="optional path to a script or config to sweep for "
"call sites that build the retired header")
args = ap.parse_args()
configured = os.environ.get("GITHUB_AUTH_HEADER")
parsed = parse_auth_header(configured)
kind = classify(parsed)
log.info("scheme: %s, secret %d char(s), %s",
kind, parsed["secret_length"],
"username present" if parsed["username_present"] else "no username")
if kind == "password-basic":
log.warning("not sending this header. A password is refused by every "
"endpoint, and posting it would put a live password in one "
"more log")
probe_status, probe_body = None, None
token = os.environ.get("GITHUB_TOKEN")
if token:
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
probe_status, probe_body = get(session, "/user")
who = (probe_body or {}).get("login", "an unnamed user") \
if isinstance(probe_body, dict) else "an unnamed user"
log.info("probe: GET /user returned %s%s", probe_status,
" as " + who if probe_status == 200 else "")
else:
log.info("set GITHUB_TOKEN to also prove the credential itself is good")
state, detail = verdict(kind, probe_status, probe_body)
log.info("%s: %s", state, detail)
if state in ("password-basic", "token-basic", "undecodable-basic",
"no-credential", "unknown-scheme"):
log.info("repair: send exactly this and delete the username field: %s",
replacement_header())
log.info("repair: Authorization: token TOKEN is still accepted if a "
"library will not emit Bearer, but Basic is not worth keeping.")
sites = []
if args.snippet_file:
with open(args.snippet_file, "r", encoding="utf-8", errors="replace") as fh:
sites = scan_snippet(fh.read())
for site in sites:
log.warning("line %d builds the retired header via %s",
site["line"], site["form"])
if not sites:
log.info("no call sites in %s build a username and password header",
args.snippet_file)
print(json.dumps({"scheme": kind, "username_present": parsed["username_present"],
"secret_length": parsed["secret_length"],
"probe_status": probe_status, "call_sites": sites,
"state": state}, indent=2))
return 0 if state == "ok" else 1
if __name__ == "__main__":
sys.exit(main())
/**
* Decide which authentication mechanism a GitHub client is using, offline.
*
* Read only, and one request: GET /user with a Bearer header built from the
* environment. The script deliberately never transmits a username and
* password, even to reproduce the documented 401.
*
* Nothing here prints the secret. The report carries the scheme, the length
* and whether a username was present.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://api.github.com';
const UA = 'github-auth-scheme-check/1.0';
/** Prefixes GitHub issues its tokens with. */
export const TOKEN_PREFIXES = ['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_'];
const LEGACY_HEX = /^[0-9a-f]{40}$/;
/** The message that means the mechanism is retired rather than the credential wrong. */
export const REMOVED = 'support for password authentication was removed';
/** Call sites that build the retired header without the word Authorization. */
export const CALL_SITES = [
['curl -u', /\bcurl\b[^\n]*?\s(-u|--user)\s/],
['Invoke-WebRequest -Credential', /-Credential\b/],
['netrc entry', /^\s*machine\s+[\w.]*github/i],
['two-string client constructor', /\b(username|user)\s*=\s*[^,\n]+,\s*password\s*=/],
];
/** Is this string shaped like a GitHub token rather than a password? Pure. */
export function looksLikeToken(secret) {
const value = String(secret ?? '');
if (TOKEN_PREFIXES.some((p) => value.startsWith(p))) return true;
return LEGACY_HEX.test(value);
}
/**
* Describe an Authorization header without revealing what is in it. Pure.
* The secret is never part of the return value, so no caller can log it.
*/
export function parseAuthHeader(value) {
const raw = String(value ?? '').trim();
if (!raw) {
return { scheme: null, username_present: false, secret_length: 0,
token_shaped: false, decoded: true };
}
const space = raw.indexOf(' ');
const scheme = (space === -1 ? raw : raw.slice(0, space)).toLowerCase();
const rest = space === -1 ? '' : raw.slice(space + 1).trim();
if (scheme !== 'basic') {
return { scheme, username_present: false, secret_length: rest.length,
token_shaped: looksLikeToken(rest), decoded: true };
}
let decoded;
try {
const buf = Buffer.from(rest, 'base64');
if (buf.toString('base64').replace(/=+$/, '') !== rest.replace(/=+$/, '')) {
throw new Error('not base64');
}
decoded = buf.toString('utf8');
} catch {
return { scheme: 'basic', username_present: false, secret_length: 0,
token_shaped: false, decoded: false };
}
const colon = decoded.indexOf(':');
const user = colon === -1 ? decoded : decoded.slice(0, colon);
const secret = colon === -1 ? '' : decoded.slice(colon + 1);
return { scheme: 'basic', username_present: Boolean(user) || colon !== -1,
secret_length: secret.length, token_shaped: looksLikeToken(secret),
decoded: true };
}
/** Name the mechanism from a parsed header. Pure. */
export function classify(parsed) {
const scheme = (parsed ?? {}).scheme;
if (scheme === null || scheme === undefined) return 'no-credential';
if (scheme === 'basic') {
if (!parsed.decoded) return 'undecodable-basic';
return parsed.token_shaped ? 'token-basic' : 'password-basic';
}
if (scheme === 'bearer') return 'bearer';
if (scheme === 'token') return 'token-scheme';
return 'unknown-scheme';
}
/** Does this response body carry the retired-mechanism message? Pure. */
export function passwordRemoved(body) {
const text = (body && typeof body === 'object')
? String(body.message ?? '') : String(body ?? '');
return text.toLowerCase().split(/\s+/).join(' ').includes(REMOVED);
}
/** The one line that replaces every retired form. Pure. */
export function replacementHeader() {
return 'Authorization: Bearer $GITHUB_TOKEN';
}
/**
* Find call sites that build a username-and-password header. Pure.
* Reports the line number and the shape, never the line itself.
*/
export function scanSnippet(text) {
const findings = [];
const lines = String(text ?? '').split('\n');
for (let i = 0; i < lines.length; i += 1) {
for (const [label, pattern] of CALL_SITES) {
if (pattern.test(lines[i])) findings.push({ line: i + 1, form: label });
}
}
return findings;
}
/** Turn the classification and the Bearer probe into a finding. Pure. */
export function verdict(kind, probeStatus, probeBody) {
if (kind === 'password-basic') {
return ['password-basic',
'the header is a username and a password. That mechanism was removed ' +
'from the API and no password will ever be accepted again. Nothing was ' +
'sent: the shape is the answer, and transmitting it would only add a ' +
'copy of the password to your proxy log.'];
}
if (kind === 'token-basic') {
return ['token-basic',
'the header is a username and a token. That still works on much of the ' +
'API, which is why it survives, but the username is meaningless and the ' +
'form is on the way out. Replace it.'];
}
if (kind === 'undecodable-basic') {
return ['undecodable-basic',
'the header says Basic but the payload is not valid base64, so ' +
'something is double-encoding or truncating it before it goes out. ' +
'GitHub will read this as no credential at all.'];
}
if (kind === 'no-credential') {
return ['no-credential',
'no Authorization header was configured, so requests go out anonymous ' +
'rather than refused, and quietly get the 60 an hour tier instead of an error.'];
}
if (kind === 'unknown-scheme') {
return ['unknown-scheme',
'the scheme is neither Basic, Bearer nor token, so GitHub will ignore ' +
'it and treat the request as unauthenticated.'];
}
if (probeStatus === 200) {
return ['ok', 'the documented scheme, and the credential behind it authenticates.'];
}
if (passwordRemoved(probeBody)) {
return ['password-removed-message',
'the scheme looks right but GitHub still answered with the ' +
'retired-mechanism message, so something downstream is rewriting the ' +
'header into Basic before it leaves.'];
}
if (probeStatus === 401) {
return ['credential-rejected',
'the mechanism is correct and the credential is not. That is a ' +
'different problem from this one: the token is wrong, revoked or ' +
'expired rather than badly wrapped.'];
}
return ['probe-inconclusive',
`the scheme is correct; the probe returned ${probeStatus} rather than 200, ` +
'so judge the credential separately.'];
}
async function get(token, path) {
const url = path.startsWith('/') ? API + path : path;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const configured = process.env.GITHUB_AUTH_HEADER;
const parsed = parseAuthHeader(configured);
const kind = classify(parsed);
console.log(`scheme: ${kind}, secret ${parsed.secret_length} char(s), ` +
`${parsed.username_present ? 'username present' : 'no username'}`);
if (kind === 'password-basic') {
console.warn('not sending this header. A password is refused by every ' +
'endpoint, and posting it would put a live password in one more log');
}
let probeStatus = null;
let probeBody = null;
const token = process.env.GITHUB_TOKEN;
if (token) {
const probe = await get(token, '/user');
probeStatus = probe.status;
probeBody = probe.body;
const who = probeBody?.login ?? 'an unnamed user';
console.log(`probe: GET /user returned ${probeStatus}` +
(probeStatus === 200 ? ` as ${who}` : ''));
} else {
console.log('set GITHUB_TOKEN to also prove the credential itself is good');
}
const [state, detail] = verdict(kind, probeStatus, probeBody);
console.log(`${state}: ${detail}`);
if (['password-basic', 'token-basic', 'undecodable-basic', 'no-credential',
'unknown-scheme'].includes(state)) {
console.log(`repair: send exactly this and delete the username field: ${replacementHeader()}`);
console.log('repair: Authorization: token TOKEN is still accepted if a ' +
'library will not emit Bearer, but Basic is not worth keeping.');
}
let sites = [];
const snippetFile = process.argv[2];
if (snippetFile) {
sites = scanSnippet(await readFile(snippetFile, 'utf8'));
for (const site of sites) {
console.warn(`line ${site.line} builds the retired header via ${site.form}`);
}
if (!sites.length) {
console.log(`no call sites in ${snippetFile} build a username and password header`);
}
}
console.log(JSON.stringify({
scheme: kind, username_present: parsed.username_present,
secret_length: parsed.secret_length, probe_status: probeStatus,
call_sites: sites, state,
}, null, 2));
process.exitCode = state === 'ok' ? 0 : 1;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and set an exit code that fails the suite.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The fixtures build their own base64 rather than hard-coding it, which keeps the fake credentials in the test obviously fake and keeps the point visible: the client can decode its own header. The assertions that matter are the two halves of the same Basic shape classifying differently, a malformed payload being reported as malformed rather than as a password, and the parser never returning the secret it just looked at.
import base64
from github_auth_scheme_check import (
classify, parse_auth_header, password_removed, replacement_header,
scan_snippet, verdict)
FAKE_TOKEN = "ghp_FAKE0000000001"
FAKE_PASSWORD = "hunter2"
def basic(user, secret):
return "Basic " + base64.b64encode(
("%s:%s" % (user, secret)).encode()).decode()
def test_a_password_and_a_token_in_the_same_shape_classify_differently():
assert classify(parse_auth_header(basic("octocat", FAKE_PASSWORD))) == "password-basic"
assert classify(parse_auth_header(basic("octocat", FAKE_TOKEN))) == "token-basic"
def test_a_forty_character_hex_secret_is_read_as_a_legacy_token():
assert classify(parse_auth_header(basic("octocat", "a" * 40))) == "token-basic"
def test_the_parser_never_returns_the_secret():
parsed = parse_auth_header(basic("octocat", FAKE_PASSWORD))
assert FAKE_PASSWORD not in str(parsed)
assert parsed["secret_length"] == len(FAKE_PASSWORD)
assert parsed["username_present"] is True
def test_bearer_and_token_schemes_are_recognised():
assert classify(parse_auth_header("Bearer " + FAKE_TOKEN)) == "bearer"
assert classify(parse_auth_header("token " + FAKE_TOKEN)) == "token-scheme"
def test_the_scheme_is_matched_case_insensitively():
assert classify(parse_auth_header("BEARER " + FAKE_TOKEN)) == "bearer"
def test_an_absent_header_is_no_credential_rather_than_an_error():
assert classify(parse_auth_header(None)) == "no-credential"
assert classify(parse_auth_header(" ")) == "no-credential"
def test_a_broken_base64_payload_is_not_reported_as_a_password():
assert classify(parse_auth_header("Basic not-base64!!")) == "undecodable-basic"
def test_an_unfamiliar_scheme_is_named_as_such():
assert classify(parse_auth_header("Negotiate abcdef")) == "unknown-scheme"
def test_the_retired_mechanism_message_is_recognised_in_a_body():
assert password_removed({"message": "Support for password authentication "
"was removed. Please use a personal "
"access token instead."}) is True
assert password_removed({"message": "Bad credentials"}) is False
def test_the_message_match_survives_odd_whitespace():
assert password_removed({"message": "support for password\nauthentication "
"was removed"}) is True
def test_a_password_header_is_never_sent():
state, detail = verdict("password-basic", None, None)
assert state == "password-basic"
assert "Nothing was sent" in detail
def test_a_username_and_token_is_flagged_even_though_it_works():
state, detail = verdict("token-basic", 200, {"login": "octo-bot"})
assert state == "token-basic"
assert "on the way out" in detail
def test_a_correct_scheme_with_a_bad_token_is_a_different_problem():
state, detail = verdict("bearer", 401, {"message": "Bad credentials"})
assert state == "credential-rejected"
assert "different problem" in detail
def test_the_retired_message_under_a_bearer_header_means_something_rewrites_it():
state, _ = verdict("bearer", 401, {"message": "Support for password "
"authentication was removed."})
assert state == "password-removed-message"
def test_a_working_bearer_header_is_the_pass():
assert verdict("bearer", 200, {"login": "octo-bot"})[0] == "ok"
def test_call_sites_are_found_by_shape_and_never_quoted():
text = "\n".join([
"curl -u octocat:%s https://api.github.com/user" % FAKE_PASSWORD,
"Invoke-WebRequest -Uri $u -Credential $c",
"client = Client(username=u, password=p)",
"curl -H \"Authorization: Bearer $T\" https://api.github.com/user",
])
sites = scan_snippet(text)
assert {s["line"] for s in sites} == {1, 2, 3}
assert FAKE_PASSWORD not in str(sites)
def test_the_replacement_is_a_header_rather_than_a_credential():
assert replacement_header() == "Authorization: Bearer $GITHUB_TOKEN"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
classify, parseAuthHeader, passwordRemoved, replacementHeader, scanSnippet, verdict,
} from './github-auth-scheme-check.mjs';
const FAKE_TOKEN = 'ghp_FAKE0000000001';
const FAKE_PASSWORD = 'hunter2';
const basic = (user, secret) =>
`Basic ${Buffer.from(`${user}:${secret}`).toString('base64')}`;
test('a password and a token in the same shape classify differently', () => {
assert.equal(classify(parseAuthHeader(basic('octocat', FAKE_PASSWORD))), 'password-basic');
assert.equal(classify(parseAuthHeader(basic('octocat', FAKE_TOKEN))), 'token-basic');
});
test('a forty character hex secret is read as a legacy token', () => {
assert.equal(classify(parseAuthHeader(basic('octocat', 'a'.repeat(40)))), 'token-basic');
});
test('the parser never returns the secret', () => {
const parsed = parseAuthHeader(basic('octocat', FAKE_PASSWORD));
assert.ok(!JSON.stringify(parsed).includes(FAKE_PASSWORD));
assert.equal(parsed.secret_length, FAKE_PASSWORD.length);
assert.equal(parsed.username_present, true);
});
test('bearer and token schemes are recognised', () => {
assert.equal(classify(parseAuthHeader(`Bearer ${FAKE_TOKEN}`)), 'bearer');
assert.equal(classify(parseAuthHeader(`token ${FAKE_TOKEN}`)), 'token-scheme');
});
test('the scheme is matched case-insensitively', () => {
assert.equal(classify(parseAuthHeader(`BEARER ${FAKE_TOKEN}`)), 'bearer');
});
test('an absent header is no credential rather than an error', () => {
assert.equal(classify(parseAuthHeader(null)), 'no-credential');
assert.equal(classify(parseAuthHeader(' ')), 'no-credential');
});
test('a broken base64 payload is not reported as a password', () => {
assert.equal(classify(parseAuthHeader('Basic not-base64!!')), 'undecodable-basic');
});
test('an unfamiliar scheme is named as such', () => {
assert.equal(classify(parseAuthHeader('Negotiate abcdef')), 'unknown-scheme');
});
test('the retired mechanism message is recognised in a body', () => {
assert.equal(passwordRemoved({
message: 'Support for password authentication was removed. Please use a ' +
'personal access token instead.',
}), true);
assert.equal(passwordRemoved({ message: 'Bad credentials' }), false);
});
test('the message match survives odd whitespace', () => {
assert.equal(passwordRemoved({
message: 'support for password\nauthentication was removed',
}), true);
});
test('a password header is never sent', () => {
const [state, detail] = verdict('password-basic', null, null);
assert.equal(state, 'password-basic');
assert.match(detail, /Nothing was sent/);
});
test('a username and token is flagged even though it works', () => {
const [state, detail] = verdict('token-basic', 200, { login: 'octo-bot' });
assert.equal(state, 'token-basic');
assert.match(detail, /on the way out/);
});
test('a correct scheme with a bad token is a different problem', () => {
const [state, detail] = verdict('bearer', 401, { message: 'Bad credentials' });
assert.equal(state, 'credential-rejected');
assert.match(detail, /different problem/);
});
test('the retired message under a bearer header means something rewrites it', () => {
const [state] = verdict('bearer', 401, {
message: 'Support for password authentication was removed.',
});
assert.equal(state, 'password-removed-message');
});
test('a working bearer header is the pass', () => {
assert.equal(verdict('bearer', 200, { login: 'octo-bot' })[0], 'ok');
});
test('call sites are found by shape and never quoted', () => {
const text = [
`curl -u octocat:${FAKE_PASSWORD} https://api.github.com/user`,
'Invoke-WebRequest -Uri $u -Credential $c',
'client = Client(username=u, password=p)',
'curl -H "Authorization: Bearer $T" https://api.github.com/user',
].join('\n');
const sites = scanSnippet(text);
assert.deepEqual([...new Set(sites.map((s) => s.line))].sort(), [1, 2, 3]);
assert.ok(!JSON.stringify(sites).includes(FAKE_PASSWORD));
});
test('the replacement is a header rather than a credential', () => {
assert.equal(replacementHeader(), 'Authorization: Bearer $GITHUB_TOKEN');
});
FAQ
I replaced the password with a token and kept the username. Is that fine?
It works on much of the API, and it is still the wrong shape. Basic base64(user:token) carries a username that GitHub does not use, encodes the secret in a form that tempts people to put it in a URL, and is the one form the documentation has been steering away from for years. The supported header is Authorization: Bearer TOKEN, with Authorization: token TOKEN accepted as the older GitHub-specific spelling. Delete the username field rather than setting it to something harmless.
Can an organization owner or an enterprise plan re-enable password authentication?
No. This is a removal rather than a setting: there is no toggle in organization settings, no plan that restores it, and no header or API version that opts back in. Anything that still works with a username and a password is not hitting the GitHub API, and anything that needs to keep working has to change its client.
Why does the script refuse to send the header to confirm the diagnosis?
Because the confirmation is already in your hand and sending it is not free. Base64 is an encoding, so the client can decode its own header and read the half after the colon without a request; making the call instead puts a live account password into GitHub's request path, your egress proxy's log and whatever captures your terminal. That is a new copy of a secret bought in exchange for information you already had.
Git push over HTTPS still asks me for a username and password. Is that the same problem?
It is the same removal wearing different clothes. Git over HTTPS also stopped accepting an account password, so the answer at that prompt is a personal access token in the password field, or better, a credential helper or SSH. The API and Git share the credential store often enough that a stale ~/.netrc entry or an old credential-helper cache will produce both failures at once, which is worth checking when the API fix does not seem to stick.
The header is a Bearer token and I still get 401. What now?
Then the mechanism is right and the credential is not, which is a different diagnosis. A 401 whose message is about bad credentials means the token is wrong, revoked or expired rather than badly wrapped, and the repair is a new token rather than a new header. The one case worth ruling out first is a proxy or SDK that rewrites the header on the way out, which is why the script separates the retired-mechanism message from every other 401 rather than treating them as one.
Related field notes
- The token is passed as a query parameter
- Requests go out anonymous, capped at 60 an hour
- A permission error disguised as 404 Not Found
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.
- Authenticating to the REST API — GitHub Docs
- Getting started with the REST API — GitHub Docs
- Keeping your API credentials secure — GitHub Docs
- Managing your personal access tokens — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.