Diagnostic GitHub API
a classic PAT passed its expiry and everything broke at once
The integration ran for eleven months without anyone touching it. At 09:14 on a Tuesday every call started returning 401 Bad credentials, all at once, on every endpoint, with no deploy, no repository change and no org announcement. A classic personal access token reached its expiry date. So did a great many other theories that morning, and the API will not tell you which one was right.
A classic PAT with an expiry stops working the moment it passes, with no grace period and no distinct error. It returns exactly the same 401 Bad credentials as a token that was revoked, truncated in transit or typed wrong, and GitHub will not elaborate: a credential it does not accept gets no explanation.
Because the message cannot distinguish them, the useful question is not why the credential failed but whether the credential is the variable at all. That is answered by running the same ladder of resources — something public, something that needs any credential, the repository, the organization — under the suspect and under a known-good control at the same instant. A suspect that fails uniformly while the control succeeds uniformly eliminates the repository, the organization, the network and your code in one pass. Anything else points somewhere other than the calendar.
The problem in plain words
The timeline is what makes this expensive. Nothing changed, so the search starts in the places where things do change: recent commits, a dependency bump, a runner image, an org policy someone might have flipped. All of those are plausible and all of them are wrong, and each one takes twenty minutes to eliminate.
The failure is also total, which cuts the wrong way. A partial failure narrows the search; a failure on every endpoint at once looks like an outage, so people check the status page, find it green, and then doubt the status page. Total, instantaneous and silent is the signature of a credential rather than a service, but only once you have seen it before.
And the 401 is a dead end by design. You cannot ask an expired token when it expired. The github-authentication-token-expiration header is only returned on successful authenticated calls, so at the moment you most want the expiry date it is unreadable. Every fact about the token you might use to confirm the theory disappeared with the token.
Why it happens
GitHub deliberately refuses to characterise a credential it rejects. Naming which of expired, revoked, malformed or unknown applies would tell an attacker whether a guessed value was ever real. So the message is constant, and the discriminator has to be built outside GitHub.
A control credential is the only variable-isolation you can run. Same process, same network, same host, same second, same URLs, one thing different. That is a controlled experiment and it is the only one available here, because everything else you might vary changes something other than the credential.
The shape of the failure is the evidence, not the status code. Expiry is total: it fails the rung that needs no credential at all, because presenting a rejected credential is worse than presenting none. A credential that answers 200 to anything has not expired, whatever else is wrong with it, and that single observation redirects the whole investigation.
Two credentials failing together is a different finding entirely. Tokens do not expire in the same second. If the control dies too, look at what the two share: the secrets store, the egress path, the organization that can revoke both, an IP allow list that changed underneath them.
The repair is a calendar entry, not a token. Re-minting takes a minute and buys you another year of exactly this. Record the expiry where the credential is stored, alert before it, and for unattended automation move to a GitHub App installation, whose one-hour tokens are minted automatically. Watching the clock on a live credential is the note next door.
The fix, as a flow
The script never claims a token expired, because nothing observable can support that. It runs one ladder of resources under the suspect and the identical ladder under a control at the same instant, then reads the two shapes side by side. What it can prove is which of the credential and the world changed.
How to fix it
Write down the minute it started, before anything else
The expiry cliff is instantaneous and identical across every caller using that credential. If two unrelated services broke in the same minute and they share a token, you have your answer before you run anything. If they broke at different times, it is not one expiry.
Run a ladder, not a single endpoint
Four rungs, in order of what they need: the REST root, which needs nothing; GET /user, which needs any valid credential; one repository; one organization. Each rung that fails tells you something different, and a failure at the first rung is the strongest single signal in the whole check.
Get a second credential and run the identical ladder
Any credential known to work: a colleague's, a fresh one, the App installation you were going to migrate to anyway. Same process, same second. Without this the report is a description of a 401 rather than a diagnosis, and the script says so in those words rather than guessing.
Read the two ladders side by side
Suspect uniformly 401, control uniformly 200 means the credential is the variable. Both failing on the same rung means the resource changed — a repository renamed, transferred or deleted answers the same way to everybody. Suspect succeeding on some rungs is not expiry at all, and the script refuses to call it that.
Re-mint, then record the expiry next to the secret
Store the expiry date in the same place as the credential, so the next person to look at the secret sees the deadline without reading a wiki. Then set the alert. A token that expires with nobody watching is not a fixed problem, it is a scheduled one.
How to check it worked
Two ladders, one table, one verdict. The line to look for is the one that names what has been eliminated, because that is the part a status page cannot give you.
GITHUB_TOKEN=... GITHUB_CONTROL_TOKEN=... \
python3 github_credential_differential.py --repo acme/api --org acme
# rung suspect control
# public 401 200
# identity 401 200
# repository 401 200
# organization 401 200
# credential-is-the-variable: the suspect answered 401 on every rung including
# the public one, at the same instant the control answered 200 on all of them.
The full code
The network half is deliberately dull: one GET per rung per credential, at most eight requests, none of which needs a scope. Everything interesting is in three pure functions — one that reduces a status code to what it says about a credential, one that names the shape of a failure across the ladder, and one that reads two shapes side by side and refuses to say expired when the evidence does not support it. Neither credential is printed, compared or inspected as text at any point.
"""Prove whether the credential is the variable, by running two of them.
Read only. One GET per rung per credential, at most eight requests, none of
which needs a scope and none of which writes.
An expired token, a revoked token and a truncated token all return the same
401 Bad credentials, so this does not try to tell them apart. It answers the
question that is actually answerable: is the credential the thing that changed,
or did the world change underneath it.
"""
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_credential_differential")
API = "https://api.github.com"
UA = "github-credential-differential/1.0"
def ladder(repo=None, org=None):
"""The rungs this run can actually probe, in order of what they need. Pure.
The public rung is first on purpose: it is the one that needs no credential
at all, so a 401 there is the strongest single observation in the check.
"""
rungs = [("public", "/"), ("identity", "/user")]
if repo:
rungs.append(("repository", "/repos/" + str(repo)))
if org:
rungs.append(("organization", "/orgs/" + str(org)))
return rungs
def outcome(status):
"""Reduce a status code to what it says about a credential. Pure."""
try:
status = int(status)
except (TypeError, ValueError):
return "error"
if status == 0:
return "error"
if 200 <= status < 300:
return "ok"
if status == 401:
return "unauthenticated"
if status == 403:
return "forbidden"
if status == 404:
return "missing"
return "other"
def shape(rows):
"""Name the signature of a failure across the ladder. Pure.
rows: [(rung, outcome), ...]
The distinction that carries the note is uniform against selective. Expiry
is total, because presenting a rejected credential is worse than presenting
none, so a credential that answers 200 to anything has not expired.
"""
results = [result for _rung, result in rows or []]
if not results:
return "nothing-probed"
if all(result == "ok" for result in results):
return "healthy"
if all(result == "unauthenticated" for result in results):
return "uniform-401"
if any(result == "ok" for result in results):
return "selective"
return "mixed"
def compare(suspect, control):
"""Line the two ladders up rung by rung. Pure.
A rung the control never ran comes back with control None and agrees False,
so a partial control cannot be mistaken for agreement.
"""
lookup = dict(control or [])
rows = []
for rung, result in suspect or []:
other = lookup.get(rung)
rows.append({"rung": rung, "suspect": result, "control": other,
"agrees": other is not None and other == result})
return rows
def diagnose(suspect, control=None):
"""Read the two ladders side by side. Pure.
Returns (state, detail). The state never says "expired", because nothing
observable distinguishes expiry from revocation or truncation. It says what
the evidence supports, which is whether the credential is the variable.
"""
suspect_shape = shape(suspect)
if suspect_shape == "nothing-probed":
return ("nothing-probed",
"no rungs were run, so there is nothing to compare.")
if not control:
if suspect_shape == "healthy":
return ("suspect-healthy",
"the suspect credential answered 200 on every rung, so "
"whatever is failing is not this credential.")
if suspect_shape == "uniform-401":
return ("no-control",
"every rung answered 401, including the one that needs no "
"credential at all. That is the signature of a value the "
"server will not accept, and expiry, revocation and a "
"truncated string all produce it identically. Without a "
"second credential run at the same instant, the evidence "
"stops here.")
return ("no-control",
"the suspect failed as %s rather than uniformly, which is not "
"what an expired credential looks like: expiry is total. "
"Without a control credential this cannot be taken further."
% suspect_shape)
rows = compare(suspect, control)
control_shape = shape(control)
if suspect_shape == "healthy":
return ("suspect-healthy",
"the suspect credential answered 200 on every rung, so whatever "
"is failing is not this credential.")
if suspect_shape == "uniform-401" and control_shape == "uniform-401":
return ("both-dead",
"both credentials answered 401 on every rung. Two tokens do not "
"expire in the same second, so look at what they share: the "
"store they came from, the network they left by, and the "
"organization that can revoke them together.")
if suspect_shape == "uniform-401" and control_shape == "healthy":
return ("credential-is-the-variable",
"the suspect answered 401 on every rung including the public "
"one, at the same instant the control answered 200 on all of "
"them. The repository, the organization, the network and your "
"code are eliminated: the credential is the only thing that "
"differs. Expiry is the common reason, and revocation and "
"truncation look identical from here.")
if all(row["agrees"] for row in rows):
failing = [row["rung"] for row in rows if row["suspect"] != "ok"]
return ("resource-changed",
"both credentials answer identically on every rung, and %s "
"failed for both. The thing that changed is the resource, not "
"the token: a repository renamed, transferred or deleted "
"answers the same way to everybody." % ", ".join(failing))
if suspect_shape == "selective":
differing = ["%s (%s)" % (row["rung"], row["suspect"])
for row in rows if not row["agrees"]]
return ("access-not-expiry",
"the suspect answered 200 on at least one rung, so it has not "
"expired: an expired credential cannot authenticate anything. "
"It differs from the control at %s. Look at what that "
"credential is allowed to reach rather than at its calendar."
% ", ".join(differing))
return ("mixed",
"the two credentials fail in different ways (%s against %s), which "
"is neither an expiry nor a changed resource. Report the rungs "
"rather than picking a story." % (suspect_shape, control_shape))
def run_ladder(token, rungs):
"""One GET per rung. Returns [(rung, outcome), ...]."""
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
rows = []
for rung, path in rungs:
try:
status = session.get(API + path, timeout=30).status_code
except requests.RequestException as exc:
log.error("GET %s failed: %s", path, exc)
status = 0
rows.append((rung, outcome(status)))
return rows
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", help="owner/name, adds a repository rung")
parser.add_argument("--org", help="login, adds an organization rung")
parser.add_argument("--env", default="GITHUB_TOKEN",
help="environment variable holding the suspect")
parser.add_argument("--control-env", default="GITHUB_CONTROL_TOKEN",
help="environment variable holding a known-good control")
args = parser.parse_args()
suspect_token = os.environ.get(args.env)
if not suspect_token:
log.error("set %s to the credential under suspicion", args.env)
return 2
rungs = ladder(args.repo, args.org)
suspect = run_ladder(suspect_token, rungs)
control_token = os.environ.get(args.control_env)
control = run_ladder(control_token, rungs) if control_token else None
if not control_token:
log.warning("%s is not set. Without a control credential this is a "
"description of a 401 rather than a diagnosis.",
args.control_env)
log.info("%-14s %-8s %s", "rung", "suspect", "control")
for row in compare(suspect, control or []):
log.info("%-14s %-8s %s", row["rung"], row["suspect"],
row["control"] or "-")
state, detail = diagnose(suspect, control)
log.info("%s: %s", state, detail)
if state == "credential-is-the-variable":
log.info("repair: re-mint the credential, then record its expiry date "
"in the same place the secret is stored and alert before it.")
log.info("repair: for unattended automation, authenticate as a GitHub "
"App installation. Its one-hour tokens are minted "
"automatically and never need a calendar entry.")
if state == "both-dead":
log.info("repair: look at the secrets store, the egress path and any "
"organization policy that could revoke both at once.")
print(json.dumps({"state": state, "suspect": suspect,
"control": control}, indent=2))
return 1 if state not in ("suspect-healthy",) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Prove whether the credential is the variable, by running two of them.
*
* Read only. One GET per rung per credential, at most eight requests, none of
* which needs a scope.
*
* An expired token, a revoked token and a truncated token all return the same
* 401, so this does not try to tell them apart. It answers the question that is
* answerable: did the credential change, or did the world.
*/
const API = 'https://api.github.com';
const UA = 'github-credential-differential/1.0';
/** The rungs this run can probe, in order of what they need. Pure. */
export function ladder(repo = null, org = null) {
const rungs = [['public', '/'], ['identity', '/user']];
if (repo) rungs.push(['repository', `/repos/${repo}`]);
if (org) rungs.push(['organization', `/orgs/${org}`]);
return rungs;
}
/** Reduce a status code to what it says about a credential. Pure. */
export function outcome(status) {
const code = Number.parseInt(status, 10);
if (!Number.isFinite(code) || code === 0) return 'error';
if (code >= 200 && code < 300) return 'ok';
if (code === 401) return 'unauthenticated';
if (code === 403) return 'forbidden';
if (code === 404) return 'missing';
return 'other';
}
/**
* Name the signature of a failure across the ladder. Pure.
* Uniform against selective is the distinction that carries the note: expiry is
* total, so a credential that answers 200 to anything has not expired.
*/
export function shape(rows) {
const results = (rows ?? []).map(([, result]) => result);
if (!results.length) return 'nothing-probed';
if (results.every((r) => r === 'ok')) return 'healthy';
if (results.every((r) => r === 'unauthenticated')) return 'uniform-401';
if (results.some((r) => r === 'ok')) return 'selective';
return 'mixed';
}
/** Line the two ladders up rung by rung. Pure. */
export function compare(suspect, control) {
const lookup = new Map(control ?? []);
return (suspect ?? []).map(([rung, result]) => {
const other = lookup.has(rung) ? lookup.get(rung) : null;
return { rung, suspect: result, control: other, agrees: other !== null && other === result };
});
}
/** Read the two ladders side by side. Pure. Never says "expired". */
export function diagnose(suspect, control = null) {
const suspectShape = shape(suspect);
if (suspectShape === 'nothing-probed') {
return ['nothing-probed', 'no rungs were run, so there is nothing to compare.'];
}
const healthy = ['suspect-healthy',
'the suspect credential answered 200 on every rung, so whatever is failing ' +
'is not this credential.'];
if (!control || !control.length) {
if (suspectShape === 'healthy') return healthy;
if (suspectShape === 'uniform-401') {
return ['no-control',
'every rung answered 401, including the one that needs no credential at ' +
'all. That is the signature of a value the server will not accept, and ' +
'expiry, revocation and a truncated string all produce it identically. ' +
'Without a second credential run at the same instant, the evidence stops here.'];
}
return ['no-control',
`the suspect failed as ${suspectShape} rather than uniformly, which is not ` +
'what an expired credential looks like: expiry is total. Without a control ' +
'credential this cannot be taken further.'];
}
const rows = compare(suspect, control);
const controlShape = shape(control);
if (suspectShape === 'healthy') return healthy;
if (suspectShape === 'uniform-401' && controlShape === 'uniform-401') {
return ['both-dead',
'both credentials answered 401 on every rung. Two tokens do not expire in ' +
'the same second, so look at what they share: the store they came from, ' +
'the network they left by, and the organization that can revoke them together.'];
}
if (suspectShape === 'uniform-401' && controlShape === 'healthy') {
return ['credential-is-the-variable',
'the suspect answered 401 on every rung including the public one, at the ' +
'same instant the control answered 200 on all of them. The repository, the ' +
'organization, the network and your code are eliminated: the credential is ' +
'the only thing that differs. Expiry is the common reason, and revocation ' +
'and truncation look identical from here.'];
}
if (rows.every((row) => row.agrees)) {
const failing = rows.filter((row) => row.suspect !== 'ok').map((row) => row.rung);
return ['resource-changed',
`both credentials answer identically on every rung, and ${failing.join(', ')} ` +
'failed for both. The thing that changed is the resource, not the token: a ' +
'repository renamed, transferred or deleted answers the same way to everybody.'];
}
if (suspectShape === 'selective') {
const differing = rows.filter((row) => !row.agrees)
.map((row) => `${row.rung} (${row.suspect})`);
return ['access-not-expiry',
'the suspect answered 200 on at least one rung, so it has not expired: an ' +
'expired credential cannot authenticate anything. It differs from the ' +
`control at ${differing.join(', ')}. Look at what that credential is ` +
'allowed to reach rather than at its calendar.'];
}
return ['mixed',
`the two credentials fail in different ways (${suspectShape} against ` +
`${controlShape}), which is neither an expiry nor a changed resource. Report ` +
'the rungs rather than picking a story.'];
}
async function runLadder(token, rungs) {
const rows = [];
for (const [rung, path] of rungs) {
let status = 0;
try {
const res = await fetch(API + path, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
status = res.status;
} catch (err) {
console.error(`GET ${path} failed: ${err.message}`);
}
rows.push([rung, outcome(status)]);
}
return rows;
}
async function main() {
const suspectToken = process.env.GITHUB_TOKEN;
if (!suspectToken) {
console.error('set GITHUB_TOKEN to the credential under suspicion');
process.exitCode = 2;
return;
}
const repo = process.argv[2] ?? null;
const org = process.argv[3] ?? null;
const rungs = ladder(repo, org);
const suspect = await runLadder(suspectToken, rungs);
const controlToken = process.env.GITHUB_CONTROL_TOKEN;
const control = controlToken ? await runLadder(controlToken, rungs) : null;
if (!controlToken) {
console.warn('GITHUB_CONTROL_TOKEN is not set. Without a control credential ' +
'this is a description of a 401 rather than a diagnosis.');
}
console.log(`${'rung'.padEnd(14)} ${'suspect'.padEnd(8)} control`);
for (const row of compare(suspect, control ?? [])) {
console.log(`${row.rung.padEnd(14)} ${row.suspect.padEnd(8)} ${row.control ?? '-'}`);
}
const [state, detail] = diagnose(suspect, control);
console.log(`${state}: ${detail}`);
if (state === 'credential-is-the-variable') {
console.log('repair: re-mint the credential, then record its expiry date in ' +
'the same place the secret is stored and alert before it.');
console.log('repair: for unattended automation, authenticate as a GitHub App ' +
'installation; its one-hour tokens need no calendar entry.');
}
if (state === 'both-dead') {
console.log('repair: look at the secrets store, the egress path and any ' +
'organization policy that could revoke both at once.');
}
console.log(JSON.stringify({ state, suspect, control }, null, 2));
process.exitCode = state === 'suspect-healthy' ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err); process.exitCode = 2; });
}
Add a test
Every interesting case here is a pair of ladders, which is exactly the thing you cannot arrange on demand: nobody has an expired token and a matching healthy one to hand at the moment they need one. Because the functions take lists of tuples, all of them are two lines. The tests that matter most are the refusals — a selective failure must not be reported as expiry, a missing control must produce no-control rather than a confident story, and two dead credentials must be a different finding from one.
from github_credential_differential import compare, diagnose, ladder, outcome, shape
DEAD = [("public", "unauthenticated"), ("identity", "unauthenticated"),
("repository", "unauthenticated")]
ALIVE = [("public", "ok"), ("identity", "ok"), ("repository", "ok")]
def test_the_ladder_only_includes_rungs_it_can_probe():
assert ladder() == [("public", "/"), ("identity", "/user")]
rungs = ladder(repo="acme/api", org="acme")
assert rungs[2] == ("repository", "/repos/acme/api")
assert rungs[3] == ("organization", "/orgs/acme")
def test_status_codes_reduce_to_what_they_say_about_a_credential():
assert outcome(200) == "ok"
assert outcome(401) == "unauthenticated"
assert outcome(403) == "forbidden"
assert outcome(404) == "missing"
assert outcome(500) == "other"
assert outcome(0) == "error"
assert outcome(None) == "error"
def test_a_total_failure_and_a_partial_one_have_different_names():
assert shape(DEAD) == "uniform-401"
assert shape(ALIVE) == "healthy"
assert shape([("public", "ok"), ("identity", "forbidden")]) == "selective"
assert shape([("public", "missing"), ("identity", "forbidden")]) == "mixed"
assert shape([]) == "nothing-probed"
def test_a_rung_the_control_never_ran_does_not_count_as_agreement():
rows = compare(ALIVE, [("public", "ok")])
assert rows[0]["agrees"] is True
assert rows[1]["control"] is None
assert rows[1]["agrees"] is False
def test_without_a_control_the_script_declines_to_name_a_cause():
state, detail = diagnose(DEAD)
assert state == "no-control"
assert "expiry, revocation and a truncated string" in detail
def test_a_uniform_401_against_a_healthy_control_is_the_credential():
state, detail = diagnose(DEAD, ALIVE)
assert state == "credential-is-the-variable"
assert "eliminated" in detail
assert "expired" not in state
def test_two_dead_credentials_are_not_two_expiries():
state, detail = diagnose(DEAD, DEAD)
assert state == "both-dead"
assert "same second" in detail
def test_identical_failures_on_one_rung_are_the_resource():
suspect = [("public", "ok"), ("identity", "ok"), ("repository", "missing")]
state, detail = diagnose(suspect, list(suspect))
assert state == "resource-changed"
assert "repository" in detail
def test_a_credential_that_authenticates_anything_has_not_expired():
suspect = [("public", "ok"), ("identity", "ok"), ("repository", "forbidden")]
state, detail = diagnose(suspect, ALIVE)
assert state == "access-not-expiry"
assert "has not expired" in detail
assert "repository (forbidden)" in detail
def test_a_healthy_suspect_sends_you_somewhere_else():
assert diagnose(ALIVE, ALIVE)[0] == "suspect-healthy"
assert diagnose(ALIVE)[0] == "suspect-healthy"
def test_disagreeing_shapes_are_reported_rather_than_narrated():
suspect = [("public", "missing"), ("identity", "forbidden")]
control = [("public", "ok"), ("identity", "unauthenticated")]
state, detail = diagnose(suspect, control)
assert state == "mixed"
assert "rather than picking a story" in detail
def test_nothing_probed_is_not_a_pass():
assert diagnose([], ALIVE)[0] == "nothing-probed"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
compare, diagnose, ladder, outcome, shape,
} from './github-credential-differential.mjs';
const DEAD = [['public', 'unauthenticated'], ['identity', 'unauthenticated'],
['repository', 'unauthenticated']];
const ALIVE = [['public', 'ok'], ['identity', 'ok'], ['repository', 'ok']];
test('the ladder only includes rungs it can probe', () => {
assert.deepEqual(ladder(), [['public', '/'], ['identity', '/user']]);
const rungs = ladder('acme/api', 'acme');
assert.deepEqual(rungs[2], ['repository', '/repos/acme/api']);
assert.deepEqual(rungs[3], ['organization', '/orgs/acme']);
});
test('status codes reduce to what they say about a credential', () => {
assert.equal(outcome(200), 'ok');
assert.equal(outcome(401), 'unauthenticated');
assert.equal(outcome(403), 'forbidden');
assert.equal(outcome(404), 'missing');
assert.equal(outcome(500), 'other');
assert.equal(outcome(0), 'error');
assert.equal(outcome(null), 'error');
});
test('a total failure and a partial one have different names', () => {
assert.equal(shape(DEAD), 'uniform-401');
assert.equal(shape(ALIVE), 'healthy');
assert.equal(shape([['public', 'ok'], ['identity', 'forbidden']]), 'selective');
assert.equal(shape([['public', 'missing'], ['identity', 'forbidden']]), 'mixed');
assert.equal(shape([]), 'nothing-probed');
});
test('a rung the control never ran does not count as agreement', () => {
const rows = compare(ALIVE, [['public', 'ok']]);
assert.equal(rows[0].agrees, true);
assert.equal(rows[1].control, null);
assert.equal(rows[1].agrees, false);
});
test('without a control the script declines to name a cause', () => {
const [state, detail] = diagnose(DEAD);
assert.equal(state, 'no-control');
assert.match(detail, /expiry, revocation and a truncated string/);
});
test('a uniform 401 against a healthy control is the credential', () => {
const [state, detail] = diagnose(DEAD, ALIVE);
assert.equal(state, 'credential-is-the-variable');
assert.match(detail, /eliminated/);
});
test('two dead credentials are not two expiries', () => {
const [state, detail] = diagnose(DEAD, DEAD);
assert.equal(state, 'both-dead');
assert.match(detail, /same second/);
});
test('identical failures on one rung are the resource', () => {
const suspect = [['public', 'ok'], ['identity', 'ok'], ['repository', 'missing']];
const [state, detail] = diagnose(suspect, suspect.map((row) => [...row]));
assert.equal(state, 'resource-changed');
assert.match(detail, /repository/);
});
test('a credential that authenticates anything has not expired', () => {
const suspect = [['public', 'ok'], ['identity', 'ok'], ['repository', 'forbidden']];
const [state, detail] = diagnose(suspect, ALIVE);
assert.equal(state, 'access-not-expiry');
assert.match(detail, /has not expired/);
assert.match(detail, /repository \(forbidden\)/);
});
test('a healthy suspect sends you somewhere else', () => {
assert.equal(diagnose(ALIVE, ALIVE)[0], 'suspect-healthy');
assert.equal(diagnose(ALIVE)[0], 'suspect-healthy');
});
test('disagreeing shapes are reported rather than narrated', () => {
const suspect = [['public', 'missing'], ['identity', 'forbidden']];
const control = [['public', 'ok'], ['identity', 'unauthenticated']];
const [state, detail] = diagnose(suspect, control);
assert.equal(state, 'mixed');
assert.match(detail, /rather than picking a story/);
});
test('nothing probed is not a pass', () => {
assert.equal(diagnose([], ALIVE)[0], 'nothing-probed');
});
FAQ
Can I ask the API when my token expired?
Not after the fact. The github-authentication-token-expiration header is only returned on successful authenticated requests, so it exists precisely while you do not need it and disappears the moment you do. A read-only credential also cannot enumerate the tokens on an account, so there is no endpoint that will list the dead one and its date. The expiry has to be recorded where the secret is stored, by you, when you mint it.
How is an expired token different from a revoked one, from the API's side?
It is not, and that is a deliberate design choice rather than an oversight. Distinguishing them would tell anyone holding a guessed value whether it was ever real. The practical consequence is that the script in this note never prints the word expired as a conclusion: it reports that the credential is the variable, and the calendar entry you kept is what turns that into expiry.
What counts as a good control credential?
Anything known to work right now, run from the same process at the same moment. A colleague's token, a freshly minted one, or the App installation you were planning to migrate to. What it must not be is a token used at a different time or from a different machine, because then you have varied two things and the comparison proves nothing.
Both credentials failed. What does that actually mean?
That the cause is something they share rather than something either of them is. In practice that is the secrets store handing out a stale or truncated value to both, an egress proxy or IP allow list that changed, or an organization action that revoked a whole class of tokens at once. The useful next step is to try a credential that did not come through the same pipeline.
Do fine-grained tokens have the same cliff?
They have a harder one. Fine-grained personal access tokens must carry an expiry, capped at 366 days unless the organization allows longer, and organizations can enforce a shorter maximum. So the question is never whether a fine-grained token will expire, only when. That makes watching the clock on a live credential the higher-value habit, which is the note next door.
Related field notes
- The token expires in days and nobody is watching
- 401 Bad credentials on every endpoint
- Org lists silently omit SSO-enforced orgs
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.
- Managing your personal access tokens — GitHub Docs
- Troubleshooting the REST API — GitHub Docs
- Authenticating to the REST API — GitHub Docs
- Authenticating as a GitHub App installation — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.