Diagnostic GitHub API
a JWT sent as token, and the 401 blames the credential
The App is registered, the private key is the right one, the JWT is freshly signed and it verifies in three different debuggers. GitHub answers 401 {"message":"Bad credentials"}. The credential in that request is perfect. The word in front of it is not, and the message never mentions words.
Authorization: Bearer <value> works for every GitHub credential. The older Authorization: token <value> works for personal access tokens, OAuth user tokens and installation access tokens, and does not work for a GitHub App JWT. That one pairing — a JWT under the token word — fails with the generic bad-credentials message, which names the credential and says nothing about the envelope.
You do not need a request to find this. A GitHub credential announces its own type in its first few characters: ghp_, gho_, ghu_, ghs_, ghr_, github_pat_, and a JWT announces itself by having three dot-separated segments. The script below reads the shape on your own machine, decides which scheme word belongs in front of it, and only then sends the same path twice — once with the word you configured and once with Bearer — so the difference is attributable to the word alone.
The problem in plain words
The message is the whole problem. Bad credentials is a sentence about the value, so every instinct it triggers is about the value: regenerate the private key, check the App id, re-copy the PEM, confirm the key was not the one that got rotated last month. People burn an afternoon generating keys, and each new key fails identically, because each new key is being handed over in an envelope GitHub will not open.
What makes it stick is that the wrong word usually works. A team writes the token spelling once, for a personal access token, five years ago. It works. It goes into the shared HTTP helper. Everything built on that helper works for years. Then somebody adds GitHub App authentication through the same helper, and the one credential type that will not accept the legacy word goes through the code path that hard-codes it.
And the near-miss is worse than the miss. Sending a PAT to an App-only endpoint gives you a different and much more helpful message about a JSON web token that could not be decoded, which at least names the subject. Sending a JWT under token gives you the same three words a dead token gives you, so the two most common App-authentication failures are indistinguishable from the response and completely different in their repairs.
Why it happens
Bearer is the general case and token is a survivor. GitHub's current documentation writes Authorization: Bearer everywhere. The token spelling predates it, is still accepted for the credential types it always covered, and is exactly the kind of thing that stays in a codebase for a decade because removing it would be a change with no visible benefit — right up until it meets the one credential type it never covered.
The credential's own text tells you its type. Since the prefixed token formats landed, a value beginning ghp_ is a classic personal access token, gho_ an OAuth user token, ghu_ a user-to-server token, ghs_ an installation access token, ghr_ a refresh token, and github_pat_ a fine-grained personal access token. None of that needs a request, which means the pairing can be checked at startup rather than discovered in production.
A JWT is recognisable without being decoded. Three dot-separated base64url segments, the first of which begins eyJ, because that is what an opening brace and a quote encode to. This script deliberately stops there and does not decode the payload: what the claims say is a genuinely different question from what word goes in front of them.
A bare value with no scheme is refused too. Authorization: ghp_… with no word at all is not a shorthand for anything. GitHub cannot tell what it is being offered and answers with the same bad-credentials message, which is the second-most common version of this and the one that survives a careless string concatenation.
The scheme word is not the only thing that can be wrong. A credential of the right type under the right word can still be refused for being dead, for belonging to the wrong account, or for being aimed at an endpoint that will not accept its type. This script answers exactly one of those questions and hands off the others rather than pretending the envelope explains everything.
The fix, as a flow
The pairing is decided locally. A GitHub credential announces its own type in its first few characters, and a JWT announces itself by having three dot separated segments, so the script knows which scheme word belongs in front of it before it opens a socket. Only the confirmation goes out: the same path, twice, with the two words.
How to fix it
Name the credential type from its own first characters
Do this locally, before anything is sent. A prefix match or a three-segment shape is all it takes, and it costs no request, no quota and no log line containing a secret. Print the type, never the value.
Look up which scheme words that type accepts
An App JWT accepts Bearer only. Personal access tokens, OAuth user tokens and installation tokens accept Bearer and the legacy token word. A refresh token accepts neither, because it is not an API credential at all: it is exchanged for one.
Parse the Authorization header your client is really building
Split it on whitespace. One word means there is no scheme at all. Two means a scheme and a value. Watch for Basic, which is a different retired mechanism with a different repair, and for anything that is neither Bearer nor token, which GitHub does not read.
Send the same path twice, with the two words
This is the only network step, and it is the experiment that isolates the variable: same path, same credential, same everything except the scheme word. If the status moves, the word was the cause. If both fail identically, the envelope is innocent and the credential is not.
Fix it once, in the place that builds the header
Not at the call site that failed. The whole failure mode is a helper that hard-codes one word for every credential it will ever carry, so the repair is to make that helper emit Bearer for all of them and then delete the alternative.
How to check it worked
The pair of statuses is the result. A 401 under token and a 200 under Bearer, from the same credential against the same path, is a complete proof that nothing about the credential needed changing.
python3 github_auth_scheme.py --path /app --scheme token
# credential type: app-jwt
# jwt-with-token-scheme: an App JWT is only read under Bearer. Under the token
# scheme it is refused with the generic bad-credentials message.
# as configured (token): 401 Bad credentials
# as recommended (Bearer): 200
# repair: change the word token to Bearer and send the same JWT
The full code
The diagnosis is local. A prefix table and a shape test name the credential type from its own first characters, an acceptance table says which scheme words that type takes, and a parser splits the header your client builds; none of them touch the network and none of them return the credential's value. Only the confirmation goes out, as two GETs to the same path that differ in one word. The 401 message table is small on purpose: GitHub uses very few sentences for authentication failures, and the useful work is mapping them onto causes rather than collecting them.
"""Check the Authorization scheme word against the shape of the credential.
Read only. The diagnosis is computed on this machine from the credential's own
first characters; the only network work is two GETs to the same path that
differ in one word, which is the experiment that isolates the variable.
Bearer works for every GitHub credential. The older token word works for
personal access tokens, OAuth user tokens and installation access tokens, and
not for a GitHub App JWT. That one pairing fails with the generic bad
credentials message, which names the credential and hides the envelope.
The credential value is never printed, logged or returned. Only its type is.
"""
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_auth_scheme")
API = "https://api.github.com"
UA = "github-auth-scheme/1.0 (+https://example.com/contact)"
# GitHub's prefixed credential formats. Longest first so github_pat_ is not
# shadowed by a shorter neighbour if this table ever grows one.
PREFIXES = (
("github_pat_", "fine-grained-pat"),
("ghp_", "classic-pat"),
("gho_", "oauth-user-token"),
("ghu_", "user-to-server-token"),
("ghs_", "installation-token"),
("ghr_", "refresh-token"),
)
# The base64url alphabet, plus padding, which is what a JWT's three segments are
# drawn from. Used for recognition only; nothing here decodes anything.
B64URL = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=")
# What each credential type accepts in front of it, lowercased. Bearer is
# correct for all of them; the legacy word is accepted for everything except an
# App JWT, which is the pairing this note exists for.
ACCEPTS = {
"app-jwt": ("bearer",),
"classic-pat": ("bearer", "token"),
"fine-grained-pat": ("bearer", "token"),
"oauth-user-token": ("bearer", "token"),
"user-to-server-token": ("bearer", "token"),
"installation-token": ("bearer", "token"),
"legacy-pat": ("bearer", "token"),
"refresh-token": (),
"unknown": ("bearer", "token"),
"absent": (),
}
# The short set of sentences GitHub uses for an authentication failure. The
# value of this table is that two of them are much more specific than the third.
MESSAGES = (
("a json web token could not be decoded",
("jwt-expected",
"the endpoint wanted an App JWT and got something that is not one. That "
"is a credential type mismatch rather than a scheme one, and it is the "
"helpful failure: it names its subject.")),
("requires authentication",
("nothing-arrived",
"no Authorization header reached GitHub at all, so the scheme word is "
"not the question yet. Something between your process and GitHub "
"dropped the header.")),
("bad credentials",
("received-and-refused",
"GitHub parsed something and did not accept it. A JWT under the token "
"word produces exactly this, and so does a dead token, so the message "
"alone does not separate them.")),
)
def looks_like_jwt(value):
"""Recognise a JWT by shape alone. Pure.
Three dot separated base64url segments, the first of which starts with the
encoding of an opening brace and a quote. The payload is deliberately not
decoded: what the claims say is a different question and a different note.
"""
if not value:
return False
parts = str(value).split(".")
if len(parts) != 3 or not all(parts):
return False
if not all(set(p) <= B64URL for p in parts):
return False
return parts[0].startswith("eyJ")
def credential_kind(value):
"""Name a credential's type from its own text. Pure.
Returns a type name and never the value, so this can be called on the real
secret and its result can go straight into a log line.
"""
if value is None or not str(value).strip():
return "absent"
text = str(value).strip()
if looks_like_jwt(text):
return "app-jwt"
for prefix, kind in PREFIXES:
if text.startswith(prefix):
return kind
if len(text) == 40 and all(c in "0123456789abcdef" for c in text.lower()):
return "legacy-pat"
return "unknown"
def parse_authorization(header):
"""Split an Authorization header into a scheme and whether a value follows.
Pure. The value is never returned: nothing downstream has a reason to hold
it, and every structure that does is another copy of the secret.
"""
if header is None:
return {"scheme": None, "has_credential": False, "words": 0}
words = str(header).split()
if not words:
return {"scheme": None, "has_credential": False, "words": 0}
if len(words) == 1:
return {"scheme": None, "has_credential": True, "words": 1}
return {"scheme": words[0], "has_credential": True, "words": len(words)}
def check_pairing(scheme, kind):
"""Decide whether a scheme word and a credential type belong together. Pure.
Returns (state, detail, repair). Five of the seven states are decided
without a request, which is the point: this is a startup assertion, not an
incident tool.
"""
word = (scheme or "").lower()
if kind == "absent":
return ("no-credential",
"there is no credential to pair a scheme with. The variable "
"holding it is empty or unset.",
"set the credential in the environment and read it from there")
if word == "basic":
return ("basic-scheme",
"Basic is a retired mechanism for this API. It fails for a "
"reason that has nothing to do with which credential you hold.",
"send Authorization: Bearer with the token instead of Basic")
if scheme is None:
return ("scheme-missing",
"the header carries a bare value with no word in front of it. "
"GitHub cannot tell what it is being offered and refuses it "
"with the same message a dead token gets.",
"prefix the value with Bearer and a single space")
if word not in ("bearer", "token"):
return ("unknown-scheme",
"%s is not a scheme this API reads. Only Bearer and the legacy "
"token word are accepted." % scheme,
"replace the scheme word with Bearer")
if kind == "refresh-token":
return ("refresh-token-sent",
"a refresh token is not an API credential under any scheme. It "
"is exchanged for a user token, and that result is what goes "
"on the wire.",
"exchange the refresh token first, then send what comes back")
if kind == "app-jwt" and word == "token":
return ("jwt-with-token-scheme",
"an App JWT is only read under Bearer. Under the token word it "
"is refused with the generic bad credentials message, which "
"names the credential and hides the envelope.",
"change the word token to Bearer and send the same JWT")
if word == "token":
return ("legacy-scheme-accepted",
"the token word still works for this credential type, so "
"nothing is failing because of it today. It is the older "
"spelling, and it is the one that breaks when the same code "
"path later carries a JWT.",
"move this helper to Bearer for every credential type")
return ("bearer-ok",
"Bearer is correct for this credential type, so the envelope is "
"not the problem. If the call still fails, the credential itself "
"is the subject.",
"none")
def explain_401(message):
"""Map GitHub's short set of authentication messages onto causes. Pure."""
text = (message or "").strip().lower().rstrip(".")
for needle, result in MESSAGES:
if needle in text:
return result
return ("unmapped-message",
"not one of the sentences GitHub uses for an authentication "
"failure, so read it literally rather than through this table.")
def get(path, scheme, token):
"""One GET under one scheme word. Returns (status, message)."""
url = API + path if path.startswith("/") else path
response = requests.get(url, timeout=30, headers={
"Authorization": "%s %s" % (scheme, token),
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
try:
body = response.json()
except ValueError:
body = None
return response.status_code, (body.get("message")
if isinstance(body, dict) else None)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--path", default="/user",
help="the API path that was refused")
ap.add_argument("--scheme", default="token",
help="the scheme word your client currently sends")
ap.add_argument("--offline", action="store_true",
help="do the pairing check and send nothing at all")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
kind = credential_kind(token)
if kind == "absent":
log.error("set GITHUB_TOKEN. There is no credential to pair a scheme "
"with, which is its own answer but not this one")
return 2
header = parse_authorization("%s %s" % (args.scheme, token))
state, detail, repair = check_pairing(header["scheme"], kind)
log.info("credential type: %s", kind)
log.info("scheme word: %s", header["scheme"] or "none, a bare value")
log.info("accepted words: %s", ", ".join(ACCEPTS.get(kind, ())) or "none")
log.info("%s: %s", state, detail)
result = {"path": args.path, "credential_type": kind,
"scheme": header["scheme"], "state": state}
if not args.offline:
configured = get(args.path, args.scheme, token)
log.info("as configured (%s): %d %s", args.scheme, configured[0],
configured[1] or "")
if configured[0] == 401:
cause, why = explain_401(configured[1])
log.info(" %s: %s", cause, why)
result["configured"] = {"scheme": args.scheme, "status": configured[0],
"message": configured[1]}
if args.scheme.lower() != "bearer":
recommended = get(args.path, "Bearer", token)
log.info("as recommended (Bearer): %d %s", recommended[0],
recommended[1] or "")
result["recommended"] = {"scheme": "Bearer",
"status": recommended[0],
"message": recommended[1]}
if recommended[0] != configured[0]:
log.info("the scheme word alone changed the outcome, which is "
"as close to proof as this gets")
elif configured[0] >= 400:
log.info("both words failed identically, so the envelope is "
"innocent. Look at the credential itself, the "
"account it belongs to, or the endpoint's own rules")
if repair != "none":
log.info("repair: %s", repair)
print(json.dumps(result, indent=2))
return 1 if state in ("jwt-with-token-scheme", "scheme-missing",
"unknown-scheme", "basic-scheme",
"refresh-token-sent") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Check the Authorization scheme word against the shape of the credential.
*
* Read only. The diagnosis is local: a credential announces its own type in
* its first few characters, so the pairing is decided before a socket opens.
* The only network work is two GETs to the same path differing in one word.
*
* The credential value is never printed, logged or returned. Only its type is.
*/
const API = 'https://api.github.com';
const UA = 'github-auth-scheme/1.0 (+https://example.com/contact)';
/** GitHub's prefixed credential formats. */
export const PREFIXES = [
['github_pat_', 'fine-grained-pat'],
['ghp_', 'classic-pat'],
['gho_', 'oauth-user-token'],
['ghu_', 'user-to-server-token'],
['ghs_', 'installation-token'],
['ghr_', 'refresh-token'],
];
/** What each credential type accepts in front of it, lowercased. */
export const ACCEPTS = {
'app-jwt': ['bearer'],
'classic-pat': ['bearer', 'token'],
'fine-grained-pat': ['bearer', 'token'],
'oauth-user-token': ['bearer', 'token'],
'user-to-server-token': ['bearer', 'token'],
'installation-token': ['bearer', 'token'],
'legacy-pat': ['bearer', 'token'],
'refresh-token': [],
unknown: ['bearer', 'token'],
absent: [],
};
const B64URL = /^[A-Za-z0-9_=-]+$/;
/**
* Recognise a JWT by shape alone. Pure.
* The payload is deliberately not decoded: what the claims say is a different
* question and a different note.
*/
export function looksLikeJwt(value) {
if (!value) return false;
const parts = String(value).split('.');
if (parts.length !== 3 || parts.some((p) => !p)) return false;
if (!parts.every((p) => B64URL.test(p))) return false;
return parts[0].startsWith('eyJ');
}
/** Name a credential's type from its own text. Pure. Never returns the value. */
export function credentialKind(value) {
if (value === null || value === undefined || !String(value).trim()) return 'absent';
const text = String(value).trim();
if (looksLikeJwt(text)) return 'app-jwt';
for (const [prefix, kind] of PREFIXES) {
if (text.startsWith(prefix)) return kind;
}
if (text.length === 40 && /^[0-9a-f]+$/.test(text.toLowerCase())) return 'legacy-pat';
return 'unknown';
}
/** Split an Authorization header into a scheme and whether a value follows. Pure. */
export function parseAuthorization(header) {
if (header === null || header === undefined) {
return { scheme: null, hasCredential: false, words: 0 };
}
const words = String(header).split(/\s+/).filter(Boolean);
if (!words.length) return { scheme: null, hasCredential: false, words: 0 };
if (words.length === 1) return { scheme: null, hasCredential: true, words: 1 };
return { scheme: words[0], hasCredential: true, words: words.length };
}
/** Decide whether a scheme word and a credential type belong together. Pure. */
export function checkPairing(scheme, kind) {
const word = String(scheme ?? '').toLowerCase();
if (kind === 'absent') {
return ['no-credential',
'there is no credential to pair a scheme with. The variable holding it ' +
'is empty or unset.',
'set the credential in the environment and read it from there'];
}
if (word === 'basic') {
return ['basic-scheme',
'Basic is a retired mechanism for this API. It fails for a reason that ' +
'has nothing to do with which credential you hold.',
'send Authorization: Bearer with the token instead of Basic'];
}
if (scheme === null || scheme === undefined) {
return ['scheme-missing',
'the header carries a bare value with no word in front of it. GitHub ' +
'cannot tell what it is being offered and refuses it with the same ' +
'message a dead token gets.',
'prefix the value with Bearer and a single space'];
}
if (word !== 'bearer' && word !== 'token') {
return ['unknown-scheme',
`${scheme} is not a scheme this API reads. Only Bearer and the legacy ` +
'token word are accepted.',
'replace the scheme word with Bearer'];
}
if (kind === 'refresh-token') {
return ['refresh-token-sent',
'a refresh token is not an API credential under any scheme. It is ' +
'exchanged for a user token, and that result is what goes on the wire.',
'exchange the refresh token first, then send what comes back'];
}
if (kind === 'app-jwt' && word === 'token') {
return ['jwt-with-token-scheme',
'an App JWT is only read under Bearer. Under the token word it is ' +
'refused with the generic bad credentials message, which names the ' +
'credential and hides the envelope.',
'change the word token to Bearer and send the same JWT'];
}
if (word === 'token') {
return ['legacy-scheme-accepted',
'the token word still works for this credential type, so nothing is ' +
'failing because of it today. It is the older spelling, and it is the ' +
'one that breaks when the same code path later carries a JWT.',
'move this helper to Bearer for every credential type'];
}
return ['bearer-ok',
'Bearer is correct for this credential type, so the envelope is not the ' +
'problem. If the call still fails, the credential itself is the subject.',
'none'];
}
/** The short set of sentences GitHub uses for an authentication failure. */
export const MESSAGES = [
['a json web token could not be decoded',
['jwt-expected',
'the endpoint wanted an App JWT and got something that is not one. That ' +
'is a credential type mismatch rather than a scheme one, and it is the ' +
'helpful failure: it names its subject.']],
['requires authentication',
['nothing-arrived',
'no Authorization header reached GitHub at all, so the scheme word is ' +
'not the question yet. Something between your process and GitHub ' +
'dropped the header.']],
['bad credentials',
['received-and-refused',
'GitHub parsed something and did not accept it. A JWT under the token ' +
'word produces exactly this, and so does a dead token, so the message ' +
'alone does not separate them.']],
];
/** Map GitHub's authentication messages onto causes. Pure. */
export function explain401(message) {
const text = String(message ?? '').trim().toLowerCase().replace(/\.+$/, '');
for (const [needle, result] of MESSAGES) {
if (text.includes(needle)) return result;
}
return ['unmapped-message',
'not one of the sentences GitHub uses for an authentication failure, so ' +
'read it literally rather than through this table.'];
}
async function get(path, scheme, token) {
const url = path.startsWith('/') ? API + path : path;
const res = await fetch(url, {
headers: {
Authorization: `${scheme} ${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 [res.status, body && typeof body === 'object' ? body.message ?? null : null];
}
function arg(name, fallback) {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : process.argv[i + 1];
}
async function main() {
const path = arg('--path', '/user');
const scheme = arg('--scheme', 'token');
const offline = process.argv.includes('--offline');
const token = process.env.GITHUB_TOKEN;
const kind = credentialKind(token);
if (kind === 'absent') {
console.error('set GITHUB_TOKEN. There is no credential to pair a scheme ' +
'with, which is its own answer but not this one');
process.exitCode = 2;
return;
}
const header = parseAuthorization(`${scheme} ${token}`);
const [state, detail, repair] = checkPairing(header.scheme, kind);
console.log(`credential type: ${kind}`);
console.log(`scheme word: ${header.scheme ?? 'none, a bare value'}`);
console.log(`accepted words: ${(ACCEPTS[kind] ?? []).join(', ') || 'none'}`);
console.log(`${state}: ${detail}`);
const result = { path, credentialType: kind, scheme: header.scheme, state };
if (!offline) {
const [status, message] = await get(path, scheme, token);
console.log(`as configured (${scheme}): ${status} ${message ?? ''}`);
if (status === 401) {
const [cause, why] = explain401(message);
console.log(` ${cause}: ${why}`);
}
result.configured = { scheme, status, message };
if (scheme.toLowerCase() !== 'bearer') {
const [status2, message2] = await get(path, 'Bearer', token);
console.log(`as recommended (Bearer): ${status2} ${message2 ?? ''}`);
result.recommended = { scheme: 'Bearer', status: status2, message: message2 };
if (status2 !== status) {
console.log('the scheme word alone changed the outcome, which is as ' +
'close to proof as this gets');
} else if (status >= 400) {
console.log('both words failed identically, so the envelope is ' +
'innocent. Look at the credential itself, the account it belongs ' +
"to, or the endpoint's own rules");
}
}
}
if (repair !== 'none') console.log(`repair: ${repair}`);
console.log(JSON.stringify(result, null, 2));
process.exitCode = ['jwt-with-token-scheme', 'scheme-missing', 'unknown-scheme',
'basic-scheme', 'refresh-token-sent'].includes(state) ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not fire live requests and set an exit code the suite then inherits.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Every fixture below is a shape rather than a secret: obviously fake stand-ins a few characters long, because the functions only ever look at prefixes and segment counts. That is what makes the whole diagnosis testable without a live App, and it is also the argument for running it as a startup assertion rather than as an incident tool.
from github_auth_scheme import (
check_pairing, credential_kind, explain_401, looks_like_jwt,
parse_authorization,
)
FAKE_JWT = "eyJhbG.eyJpc3M.sig"
def test_a_jwt_is_recognised_by_shape_without_being_decoded():
assert looks_like_jwt(FAKE_JWT) is True
assert looks_like_jwt("eyJhbG.eyJpc3M") is False
assert looks_like_jwt("eyJhbG..sig") is False
assert looks_like_jwt("not.a.jwt") is False
assert looks_like_jwt("") is False
def test_each_prefix_names_its_credential_type():
assert credential_kind("ghp_fake") == "classic-pat"
assert credential_kind("gho_fake") == "oauth-user-token"
assert credential_kind("ghu_fake") == "user-to-server-token"
assert credential_kind("ghs_fake") == "installation-token"
assert credential_kind("ghr_fake") == "refresh-token"
assert credential_kind("github_pat_fk") == "fine-grained-pat"
def test_a_jwt_wins_over_the_prefix_table():
assert credential_kind(FAKE_JWT) == "app-jwt"
def test_the_unprefixed_legacy_shape_is_still_recognised():
assert credential_kind("0" * 40) == "legacy-pat"
assert credential_kind("something-else") == "unknown"
assert credential_kind(None) == "absent"
assert credential_kind(" ") == "absent"
def test_a_bare_value_has_no_scheme():
assert parse_authorization("ghp_fake")["scheme"] is None
assert parse_authorization("ghp_fake")["has_credential"] is True
def test_a_scheme_and_a_value_are_split_on_whitespace():
parsed = parse_authorization("Bearer ghp_fake")
assert parsed["scheme"] == "Bearer"
assert parsed["words"] == 2
def test_an_absent_header_is_not_an_empty_one():
assert parse_authorization(None)["has_credential"] is False
assert parse_authorization("")["has_credential"] is False
def test_a_jwt_under_the_token_word_is_the_headline_failure():
state, detail, repair = check_pairing("token", "app-jwt")
assert state == "jwt-with-token-scheme"
assert "Bearer" in detail
assert "Bearer" in repair
def test_a_jwt_under_bearer_is_fine():
assert check_pairing("Bearer", "app-jwt")[0] == "bearer-ok"
def test_the_scheme_word_is_read_case_insensitively():
assert check_pairing("bearer", "app-jwt")[0] == "bearer-ok"
assert check_pairing("TOKEN", "app-jwt")[0] == "jwt-with-token-scheme"
def test_a_pat_under_the_legacy_word_works_and_is_still_reported():
state, detail, _ = check_pairing("token", "classic-pat")
assert state == "legacy-scheme-accepted"
assert "nothing is failing because of it today" in detail
def test_a_bare_value_is_its_own_state():
assert check_pairing(None, "classic-pat")[0] == "scheme-missing"
def test_basic_is_sent_to_the_other_note():
assert check_pairing("Basic", "classic-pat")[0] == "basic-scheme"
def test_an_unread_scheme_word_is_named():
state, detail, _ = check_pairing("OAuth", "classic-pat")
assert state == "unknown-scheme"
assert "OAuth" in detail
def test_a_refresh_token_is_not_an_api_credential():
assert check_pairing("Bearer", "refresh-token")[0] == "refresh-token-sent"
def test_no_credential_is_not_a_scheme_problem():
assert check_pairing("Bearer", "absent")[0] == "no-credential"
def test_the_specific_messages_beat_the_generic_one():
assert explain_401("A JSON web token could not be decoded.")[0] == "jwt-expected"
assert explain_401("Requires authentication")[0] == "nothing-arrived"
assert explain_401("Bad credentials")[0] == "received-and-refused"
def test_an_unfamiliar_message_is_admitted_rather_than_guessed():
assert explain_401("Something else entirely")[0] == "unmapped-message"
assert explain_401(None)[0] == "unmapped-message"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
checkPairing, credentialKind, explain401, looksLikeJwt, parseAuthorization,
} from './github-auth-scheme.mjs';
const FAKE_JWT = 'eyJhbG.eyJpc3M.sig';
test('a jwt is recognised by shape without being decoded', () => {
assert.equal(looksLikeJwt(FAKE_JWT), true);
assert.equal(looksLikeJwt('eyJhbG.eyJpc3M'), false);
assert.equal(looksLikeJwt('eyJhbG..sig'), false);
assert.equal(looksLikeJwt('not.a.jwt'), false);
assert.equal(looksLikeJwt(''), false);
});
test('each prefix names its credential type', () => {
assert.equal(credentialKind('ghp_fake'), 'classic-pat');
assert.equal(credentialKind('gho_fake'), 'oauth-user-token');
assert.equal(credentialKind('ghu_fake'), 'user-to-server-token');
assert.equal(credentialKind('ghs_fake'), 'installation-token');
assert.equal(credentialKind('ghr_fake'), 'refresh-token');
assert.equal(credentialKind('github_pat_fk'), 'fine-grained-pat');
});
test('a jwt wins over the prefix table', () => {
assert.equal(credentialKind(FAKE_JWT), 'app-jwt');
});
test('the unprefixed legacy shape is still recognised', () => {
assert.equal(credentialKind('0'.repeat(40)), 'legacy-pat');
assert.equal(credentialKind('something-else'), 'unknown');
assert.equal(credentialKind(null), 'absent');
assert.equal(credentialKind(' '), 'absent');
});
test('a bare value has no scheme', () => {
assert.equal(parseAuthorization('ghp_fake').scheme, null);
assert.equal(parseAuthorization('ghp_fake').hasCredential, true);
});
test('a scheme and a value are split on whitespace', () => {
const parsed = parseAuthorization('Bearer ghp_fake');
assert.equal(parsed.scheme, 'Bearer');
assert.equal(parsed.words, 2);
});
test('an absent header is not an empty one', () => {
assert.equal(parseAuthorization(null).hasCredential, false);
assert.equal(parseAuthorization('').hasCredential, false);
});
test('a jwt under the token word is the headline failure', () => {
const [state, detail, repair] = checkPairing('token', 'app-jwt');
assert.equal(state, 'jwt-with-token-scheme');
assert.ok(detail.includes('Bearer'));
assert.ok(repair.includes('Bearer'));
});
test('a jwt under bearer is fine', () => {
assert.equal(checkPairing('Bearer', 'app-jwt')[0], 'bearer-ok');
});
test('the scheme word is read case insensitively', () => {
assert.equal(checkPairing('bearer', 'app-jwt')[0], 'bearer-ok');
assert.equal(checkPairing('TOKEN', 'app-jwt')[0], 'jwt-with-token-scheme');
});
test('a pat under the legacy word works and is still reported', () => {
const [state, detail] = checkPairing('token', 'classic-pat');
assert.equal(state, 'legacy-scheme-accepted');
assert.ok(detail.includes('nothing is failing because of it today'));
});
test('a bare value is its own state', () => {
assert.equal(checkPairing(null, 'classic-pat')[0], 'scheme-missing');
});
test('basic is sent to the other note', () => {
assert.equal(checkPairing('Basic', 'classic-pat')[0], 'basic-scheme');
});
test('an unread scheme word is named', () => {
const [state, detail] = checkPairing('OAuth', 'classic-pat');
assert.equal(state, 'unknown-scheme');
assert.ok(detail.includes('OAuth'));
});
test('a refresh token is not an api credential', () => {
assert.equal(checkPairing('Bearer', 'refresh-token')[0], 'refresh-token-sent');
});
test('no credential is not a scheme problem', () => {
assert.equal(checkPairing('Bearer', 'absent')[0], 'no-credential');
});
test('the specific messages beat the generic one', () => {
assert.equal(explain401('A JSON web token could not be decoded.')[0], 'jwt-expected');
assert.equal(explain401('Requires authentication')[0], 'nothing-arrived');
assert.equal(explain401('Bad credentials')[0], 'received-and-refused');
});
test('an unfamiliar message is admitted rather than guessed', () => {
assert.equal(explain401('Something else entirely')[0], 'unmapped-message');
assert.equal(explain401(null)[0], 'unmapped-message');
});
FAQ
Is token deprecated, or does it still work?
It still works, for the credential types it always covered: personal access tokens, OAuth user tokens and installation access tokens. GitHub's current documentation writes Bearer everywhere, and Bearer is accepted for all of those too, so there is no case where token is the only thing that works and one important case where it is the thing that fails. Treat it as a spelling to migrate away from rather than an error to panic about, and migrate it in the helper that builds the header rather than at each call site.
Why does a JWT under token give the same message as a dead token?
Because GitHub is deliberately unhelpful about credentials it will not accept. Bad credentials is what you get whenever something was received in the Authorization header and refused, and it is identical for a refused JWT, an expired token, a truncated value and a token from a different account. That is a security property rather than an oversight, and it is why the diagnosis has to come from the shape of what you sent rather than from what came back.
Can I tell what kind of credential I am holding without calling the API?
Yes, for anything minted since GitHub introduced prefixed token formats. ghp_ is a classic personal access token, gho_ an OAuth user token, ghu_ a user-to-server token, ghs_ an installation access token, ghr_ a refresh token, and github_pat_ a fine-grained personal access token. A JWT is three dot-separated base64url segments starting eyJ. The one gap is the old unprefixed forty-character hexadecimal token, which is recognisable by shape but says nothing about its own type.
The header has the right word and the call still fails. What now?
Then the envelope is innocent and the subject is the credential, the identity behind it, or the endpoint. Run the pair anyway: two identical failures under two different words is a real result and it rules out this whole page in one step. After that the questions are whether the credential is alive at all, whether it belongs to the account you think it does, and whether the endpoint accepts that credential type, and each of those is a separate check.
Should I ever send Authorization without a scheme word at all?
No. A bare value in the header is not a shorthand that GitHub tolerates; it is refused with the same message a dead credential gets, which makes it one of the more expensive typos available. It usually arrives through string formatting: a template that lost its prefix, a configuration value that was supposed to include the word and did not, or two layers that each assume the other adds it. Asserting on the parsed scheme at startup costs nothing and catches all three.
Related field notes
- 401 Bad credentials on every endpoint
- A username and password sent to the API
- The token passed as a query parameter
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
- Generating a JSON Web Token for a GitHub App — GitHub Docs
- Authenticating with a GitHub App — GitHub Docs
- Troubleshooting 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.