Diagnostic GitHub API
a permission error is disguised as 404 Not Found
The repository is open in a browser tab in front of you. The script asks for the same repository and gets 404 {"message":"Not Found"}. Somebody checks the spelling, then the owner name, then the case of both, then writes a ticket saying the API is broken. The API is not broken. It is refusing to tell you that the repository exists, because telling you would leak the existence of a private repository to a credential that has no business knowing about it.
Treat the 404 as a missing fact, not a missing resource, and go and get the fact from somewhere else. Three cheap reads settle it: GET /user says whether the token authenticates at all and carries x-oauth-scopes; GET /repos/{owner}/{repo} is the call that failed; and for a GitHub App, GET /installation/repositories says whether the repository is inside the installation.
The four answers those reads distinguish — dead token, missing scope, repository not in the installation, and no grant at all — have four different repairs and one identical status code.
The problem in plain words
Every other API you integrate with uses 403 to mean "you are who you say you are and you may not have this". GitHub uses 404, deliberately, on private resources. The reasoning is sound: a 403 on /repos/acme/project-nightingale would confirm that acme has a repository called project-nightingale, and for a private repository that confirmation is itself the leak. So the API declines to distinguish "no such thing" from "not for you".
The cost lands on the person debugging. A 404 reads as a typo, and typos are the first thing anyone checks, so the first hour goes to spelling and the second to whether the repository was deleted. Meanwhile the actual cause is a token minted with public_repo instead of repo, or an App installed on the organization but never ticked for this repository, and neither of those is anywhere near where anyone is looking.
Why it happens
The masking is a documented design decision, not a bug. GitHub returns 404 instead of 403 on private resources specifically to avoid confirming that they exist. No header, no error code and no message distinguishes the two cases, because a distinguishable response would defeat the point.
Classic scopes are coarse and silent. A classic token with public_repo can read every public repository on the platform and no private ones. Against a private repository it does not get "insufficient scope"; it gets the same 404 an anonymous request gets. The x-oauth-scopes header on any authenticated response names what the token actually carries, which is the only place that difference is visible.
Fine-grained tokens and App installations grant repositories one at a time. Both models replace "everything the user can see" with an explicit list. A repository absent from that list is outside the credential's world entirely, and being outside the credential's world is indistinguishable, over HTTP, from not existing.
A dead token 404s everything private and 200s everything public. That combination is the most misleading of all, because the script visibly works. Public repositories answer, private ones do not, and the shape looks exactly like a permissions problem on specific repositories rather than a credential that expired last Tuesday.
The fix, as a flow
The script spends three cheap reads before it says anything, because a 404 on its own carries no information at all: the fact that separates a dead token from a missing scope from a missing installation lives on a different endpoint every time.
How to fix it
Establish that the credential is alive before anything else
GET /user. A 200 gives you the login the token belongs to, which is worth reading out loud: half of these incidents end with somebody realising the CI job holds a different account's token. A 401 Bad credentials here means every 404 downstream is noise, and the repository question cannot be answered until the token is replaced.
Read the scopes off that same response
x-oauth-scopes lists what a classic or OAuth token carries. Keep absent and empty apart: an empty header means a classic token with nothing ticked, and no header at all means a fine-grained token or an App installation token, which do not use scopes. Those two look the same if you parse carelessly and they need opposite repairs.
Name the credential from its prefix, locally
ghp_ classic PAT, github_pat_ fine-grained PAT, gho_ OAuth user token, ghs_ App installation token, ghu_ user-to-server, ghr_ refresh token. This is a string comparison on a value you already hold, costs no request, and decides which of the following checks is even meaningful.
For an App, ask what the installation actually contains
GET /installation/repositories?per_page=100, paged. If the repository is in that list and GET /repos/{owner}/{repo} still 404s, the App is installed on it and lacks Metadata: Read, which every repository endpoint requires. If it is not in the list, the installation is set to selected repositories and this one was never ticked.
Print all the signals, not the status code
The output that ends the incident is not "404". It is "token ghp_ authenticates as ci-bot, scopes public_repo, repository not readable" — at which point nobody checks the spelling, because the answer is on the screen. Where every signal is healthy and the 404 persists, say that plainly too: an account with no grant and a repository that was genuinely deleted are the same response, and pretending otherwise is worse than admitting it.
How to check it worked
Re-run the script against the same repository after the repair. The verdict should be visible, and the login it reports should be the account you meant to use.
python3 github_404_triage.py acme/project-nightingale
# visible acme/project-nightingale authenticated as ci-bot; the repository answered 200
The full code
Three GET requests at most and no writes at all — a read-only token is enough, and is what you should give it. The two pure functions are the prefix reader and the verdict, because the whole value of this note is the branching: five identical 404s that mean five different things, laid out somewhere you can read the rules rather than infer them from a stack trace.
"""Tell apart the several different failures GitHub hides behind one 404.
Read only. GET requests and nothing else: a token with read access is enough.
The repair is printed, never performed, because this script holds a credential
that can reach private repositories.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_404_triage")
API = "https://api.github.com"
UA = "github-404-triage/1.0"
# Longest prefixes first so a future prefix that extends an existing one cannot
# be swallowed by its shorter neighbour.
PREFIXES = (
("github_pat_", "fine-grained PAT"),
("ghp_", "classic PAT"),
("gho_", "OAuth user token"),
("ghu_", "App user-to-server token"),
("ghs_", "App installation token"),
("ghr_", "App refresh token"),
)
def token_kind(token):
"""Name the credential from its prefix. Pure, and it never leaves the machine.
Which check is worth making depends entirely on what kind of token this is:
scopes are meaningless for an App installation token, and the installation
question is meaningless for a classic PAT. A prefix comparison answers that
for free, before a single request is spent.
"""
value = (token or "").strip()
for prefix, name in PREFIXES:
if value.startswith(prefix):
return name
return "unknown"
def scope_list(header_value):
"""Read x-oauth-scopes into a list, keeping absent and empty apart.
A classic token with nothing ticked sends the header with an empty value.
A fine-grained token or an App token does not send it at all. Collapsing both
to an empty list loses the one signal that decides which repair to print, so
absence is None and emptiness is [].
"""
if header_value is None:
return None
return [s.strip() for s in header_value.split(",") if s.strip()]
def verdict(probe):
"""Classify one 404. Pure, so the rules are readable rather than inferred.
`probe` carries what the reads found: repo_status, authenticated, scopes
(None when the token does not use them), token_kind, and in_installation
(None when the question does not apply). Returns (state, detail).
"""
status = probe.get("repo_status")
if not probe.get("authenticated"):
return ("bad-credentials",
"GET /user did not authenticate. Every private repository 404s "
"for a dead token while every public one answers 200, which is "
"why this looks like a per-repository permission problem.")
if status == 200:
return ("visible", "the repository answered 200")
if status == 403:
return ("plain-403",
"403 rather than 404, which is the honest one: rate limit, org "
"IP allow list, or a policy that blocks this app. Read the "
"message body and x-ratelimit-remaining before assuming access.")
if status != 404:
return ("unexpected", "HTTP %s is not the masked case" % (status,))
kind = probe.get("token_kind")
if kind == "App installation token":
inside = probe.get("in_installation")
if inside is True:
return ("metadata-permission",
"the repository is inside the installation, so it exists and "
"you reach it. Every repository endpoint requires "
"Metadata: Read; without it the repository itself 404s.")
if inside is False:
return ("not-in-installation",
"the installation does not include this repository. "
"repository_selection is 'selected' and this one was never "
"ticked, so it is outside the token's world entirely.")
return ("installation-unknown",
"GET /installation/repositories could not be read, so the "
"installation question is open. Retry with the installation "
"token the failing call actually uses.")
scopes = probe.get("scopes")
if scopes is None:
return ("repository-not-granted",
"no x-oauth-scopes header, so this is a fine-grained token. "
"Those grant repositories one at a time: this one is not in the "
"token's repository list, or Metadata: Read is not on it.")
if "repo" not in scopes:
return ("missing-scope",
"the token carries %s and not 'repo'. Public repositories answer "
"and private ones return exactly this 404."
% (", ".join(scopes) or "no scopes at all",))
return ("no-access-or-gone",
"the token authenticates and carries 'repo', so the scope is not the "
"problem. What is left is an account that was never granted access, "
"or a repository that is genuinely gone. GitHub returns the same 404 "
"for both on purpose and no header separates them.")
def get(session, url, **params):
return session.get(url, params=params, timeout=30)
def installation_repos(session, api, limit=2000):
"""Every repository inside this installation, or None if it cannot be read.
Paged rather than trusted from one page: total_count is the size of the
installation, and the repositories array is one page of it.
"""
out = []
page = 1
while len(out) < limit:
r = get(session, api + "/installation/repositories", per_page=100, page=page)
if r.status_code != 200:
return None
items = r.json().get("repositories", [])
out.extend(items)
if len(items) < 100:
break
page += 1
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("repo", help="owner/name of the repository that returns 404")
ap.add_argument("--api", default=API,
help="API host, for GitHub Enterprise Server")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough)")
return 2
if "/" not in args.repo:
log.error("pass the repository as owner/name")
return 2
owner, name = args.repo.split("/", 1)
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright, which is its own
# confusing 403 and not the one this script is about.
"User-Agent": UA,
})
kind = token_kind(token)
me = get(session, args.api + "/user")
probe = {
"token_kind": kind,
"authenticated": me.status_code == 200,
"scopes": scope_list(me.headers.get("x-oauth-scopes")),
"in_installation": None,
}
login = me.json().get("login") if probe["authenticated"] else None
repo = get(session, "%s/repos/%s/%s" % (args.api, owner, name))
probe["repo_status"] = repo.status_code
if kind == "App installation token" and repo.status_code == 404:
repos = installation_repos(session, args.api)
if repos is not None:
full = args.repo.lower()
probe["in_installation"] = any(
str(r.get("full_name") or "").lower() == full for r in repos)
state, detail = verdict(probe)
line = "%-22s %s %s" % (state, args.repo, detail)
if state == "visible":
log.info("%s (authenticated as %s)", line, login)
return 0
log.warning(line)
log.warning(" token: %s, login: %s, scopes: %s", kind, login,
"absent" if probe["scopes"] is None else (probe["scopes"] or "none"))
repairs = {
"bad-credentials": "re-mint the token and assert GET /user returns the "
"expected login at startup",
"missing-scope": "re-create the classic token with the 'repo' scope, or "
"move to a fine-grained token listing this repository",
"repository-not-granted": "add this repository to the fine-grained "
"token's repository access, with Metadata: Read",
"not-in-installation": "add the repository to the App installation, or "
"switch the installation to All repositories",
"metadata-permission": "add Metadata: Read to the App and have each "
"installation accept the updated permissions",
"no-access-or-gone": "grant %s access to the repository, or confirm with "
"somebody who can see it that it still exists" % (login,),
}
if state in repairs:
log.warning(" repair: %s", repairs[state])
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Tell apart the several different failures GitHub hides behind one 404.
*
* Read only. GET requests and nothing else: a token with read access is enough.
* The repair is printed, never performed.
*/
const API = 'https://api.github.com';
const UA = 'github-404-triage/1.0';
// Longest prefixes first so a future prefix that extends an existing one cannot
// be swallowed by its shorter neighbour.
const PREFIXES = [
['github_pat_', 'fine-grained PAT'],
['ghp_', 'classic PAT'],
['gho_', 'OAuth user token'],
['ghu_', 'App user-to-server token'],
['ghs_', 'App installation token'],
['ghr_', 'App refresh token'],
];
/**
* Name the credential from its prefix. Pure, and it never leaves the machine.
*/
export function tokenKind(token) {
const value = String(token ?? '').trim();
for (const [prefix, name] of PREFIXES) {
if (value.startsWith(prefix)) return name;
}
return 'unknown';
}
/**
* Read x-oauth-scopes into an array, keeping absent (null) and empty ([]) apart.
* A classic token with nothing ticked sends an empty header; a fine-grained or
* App token sends none at all, and those need opposite repairs.
*/
export function scopeList(headerValue) {
if (headerValue === null || headerValue === undefined) return null;
return headerValue.split(',').map((s) => s.trim()).filter(Boolean);
}
/**
* Classify one 404. Pure, so the rules are readable rather than inferred.
* Returns [state, detail].
*/
export function verdict(probe) {
const status = probe.repo_status;
if (!probe.authenticated) {
return ['bad-credentials',
'GET /user did not authenticate. Every private repository 404s for a dead ' +
'token while every public one answers 200, which is why this looks like a ' +
'per-repository permission problem.'];
}
if (status === 200) return ['visible', 'the repository answered 200'];
if (status === 403) {
return ['plain-403',
'403 rather than 404, which is the honest one: rate limit, org IP allow ' +
'list, or a policy that blocks this app. Read the message body and ' +
'x-ratelimit-remaining before assuming access.'];
}
if (status !== 404) return ['unexpected', `HTTP ${status} is not the masked case`];
if (probe.token_kind === 'App installation token') {
const inside = probe.in_installation;
if (inside === true) {
return ['metadata-permission',
'the repository is inside the installation, so it exists and you reach ' +
'it. Every repository endpoint requires Metadata: Read; without it the ' +
'repository itself 404s.'];
}
if (inside === false) {
return ['not-in-installation',
"the installation does not include this repository. repository_selection " +
"is 'selected' and this one was never ticked, so it is outside the " +
"token's world entirely."];
}
return ['installation-unknown',
'GET /installation/repositories could not be read, so the installation ' +
'question is open. Retry with the installation token the failing call ' +
'actually uses.'];
}
const scopes = probe.scopes;
if (scopes === null || scopes === undefined) {
return ['repository-not-granted',
'no x-oauth-scopes header, so this is a fine-grained token. Those grant ' +
'repositories one at a time: this one is not in the token\'s repository ' +
'list, or Metadata: Read is not on it.'];
}
if (!scopes.includes('repo')) {
return ['missing-scope',
`the token carries ${scopes.join(', ') || 'no scopes at all'} and not ` +
"'repo'. Public repositories answer and private ones return exactly this 404."];
}
return ['no-access-or-gone',
"the token authenticates and carries 'repo', so the scope is not the " +
'problem. What is left is an account that was never granted access, or a ' +
'repository that is genuinely gone. GitHub returns the same 404 for both on ' +
'purpose and no header separates them.'];
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
// GitHub rejects requests with no User-Agent outright, which is its own
// confusing 403 and not the one this script is about.
'User-Agent': UA,
};
}
async function get(token, url, params = {}) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
return fetch(u, { headers: headers(token) });
}
export async function installationRepos(token, api, limit = 2000) {
const out = [];
let page = 1;
while (out.length < limit) {
const res = await get(token, `${api}/installation/repositories`,
{ per_page: 100, page });
if (res.status !== 200) return null;
const items = (await res.json()).repositories ?? [];
out.push(...items);
if (items.length < 100) break;
page += 1;
}
return out;
}
async function main() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error('set GITHUB_TOKEN (a read-only token is enough)');
process.exitCode = 2;
return;
}
const target = process.argv[2];
if (!target || !target.includes('/')) {
console.error('pass the repository as owner/name');
process.exitCode = 2;
return;
}
const kind = tokenKind(token);
const me = await get(token, `${API}/user`);
const probe = {
token_kind: kind,
authenticated: me.status === 200,
scopes: scopeList(me.headers.get('x-oauth-scopes')),
in_installation: null,
};
const login = probe.authenticated ? (await me.json()).login : null;
const repo = await get(token, `${API}/repos/${target}`);
probe.repo_status = repo.status;
if (kind === 'App installation token' && repo.status === 404) {
const repos = await installationRepos(token, API);
if (repos !== null) {
const full = target.toLowerCase();
probe.in_installation = repos.some(
(r) => String(r.full_name ?? '').toLowerCase() === full);
}
}
const [state, detail] = verdict(probe);
const line = `${state.padEnd(22)} ${target} ${detail}`;
if (state === 'visible') {
console.log(`${line} (authenticated as ${login})`);
return;
}
console.warn(line);
console.warn(` token: ${kind}, login: ${login}, scopes: ` +
`${probe.scopes === null ? 'absent' : (probe.scopes.join(', ') || 'none')}`);
const repairs = {
'bad-credentials': 're-mint the token and assert GET /user returns the ' +
'expected login at startup',
'missing-scope': "re-create the classic token with the 'repo' scope, or move " +
'to a fine-grained token listing this repository',
'repository-not-granted': "add this repository to the fine-grained token's " +
'repository access, with Metadata: Read',
'not-in-installation': 'add the repository to the App installation, or switch ' +
'the installation to All repositories',
'metadata-permission': 'add Metadata: Read to the App and have each ' +
'installation accept the updated permissions',
'no-access-or-gone': `grant ${login} access to the repository, or confirm ` +
'with somebody who can see it that it still exists',
};
if (repairs[state]) console.warn(` repair: ${repairs[state]}`);
process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two rules carry this note and both are easy to get wrong. An absent x-oauth-scopes header is not an empty one: absent means a fine-grained or App token, empty means a classic token with nothing ticked, and they lead to different pages of GitHub's settings. And the last state has to stay honest — when the token is alive, carries repo, and the repository still 404s, the script must say that no-grant and genuinely-deleted are the same response rather than pick the friendlier one.
from github_404_triage import scope_list, token_kind, verdict
def probe(**kw):
base = {"repo_status": 404, "authenticated": True, "scopes": ["repo"],
"token_kind": "classic PAT", "in_installation": None}
base.update(kw)
return base
def test_prefixes_name_the_credential_without_sending_it():
assert token_kind("ghp_abc123") == "classic PAT"
assert token_kind("github_pat_11ABCDE") == "fine-grained PAT"
assert token_kind("ghs_installation") == "App installation token"
assert token_kind(" gho_padded ") == "OAuth user token"
assert token_kind("v1.0123deadbeef") == "unknown"
assert token_kind(None) == "unknown"
def test_absent_scopes_header_is_not_an_empty_one():
# The whole branch between "fine-grained token" and "classic token with
# nothing ticked" hangs on this distinction.
assert scope_list(None) is None
assert scope_list("") == []
assert scope_list("repo, read:org") == ["repo", "read:org"]
def test_dead_token_beats_every_other_reading():
state, detail = verdict(probe(authenticated=False, scopes=None))
assert state == "bad-credentials"
assert "public" in detail
def test_a_repository_that_answers_is_visible():
assert verdict(probe(repo_status=200))[0] == "visible"
def test_a_real_403_is_reported_as_the_honest_one():
state, detail = verdict(probe(repo_status=403))
assert state == "plain-403"
assert "rate limit" in detail
def test_classic_token_without_repo_scope_names_the_scope():
state, detail = verdict(probe(scopes=["public_repo"]))
assert state == "missing-scope"
assert "public_repo" in detail
def test_no_scopes_at_all_is_still_a_classic_token():
state, detail = verdict(probe(scopes=[]))
assert state == "missing-scope"
assert "no scopes at all" in detail
def test_missing_scope_header_means_a_fine_grained_token():
state, _ = verdict(probe(scopes=None, token_kind="fine-grained PAT"))
assert state == "repository-not-granted"
def test_app_token_outside_the_installation_is_its_own_state():
state, _ = verdict(probe(token_kind="App installation token",
scopes=None, in_installation=False))
assert state == "not-in-installation"
def test_app_token_inside_the_installation_points_at_metadata():
state, detail = verdict(probe(token_kind="App installation token",
scopes=None, in_installation=True))
assert state == "metadata-permission"
assert "Metadata" in detail
def test_the_indistinguishable_case_stays_indistinguishable():
# Alive, scoped, still 404. The script must not guess which of the two it is.
state, detail = verdict(probe())
assert state == "no-access-or-gone"
assert "same 404" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scopeList, tokenKind, verdict } from './github-404-triage.mjs';
const probe = (over = {}) => ({
repo_status: 404, authenticated: true, scopes: ['repo'],
token_kind: 'classic PAT', in_installation: null, ...over,
});
test('prefixes name the credential without sending it', () => {
assert.equal(tokenKind('ghp_abc123'), 'classic PAT');
assert.equal(tokenKind('github_pat_11ABCDE'), 'fine-grained PAT');
assert.equal(tokenKind('ghs_installation'), 'App installation token');
assert.equal(tokenKind(' gho_padded '), 'OAuth user token');
assert.equal(tokenKind('v1.0123deadbeef'), 'unknown');
assert.equal(tokenKind(null), 'unknown');
});
test('an absent scopes header is not an empty one', () => {
assert.equal(scopeList(null), null);
assert.deepEqual(scopeList(''), []);
assert.deepEqual(scopeList('repo, read:org'), ['repo', 'read:org']);
});
test('dead token beats every other reading', () => {
const [state, detail] = verdict(probe({ authenticated: false, scopes: null }));
assert.equal(state, 'bad-credentials');
assert.match(detail, /public/);
});
test('a repository that answers is visible', () => {
assert.equal(verdict(probe({ repo_status: 200 }))[0], 'visible');
});
test('a real 403 is reported as the honest one', () => {
const [state, detail] = verdict(probe({ repo_status: 403 }));
assert.equal(state, 'plain-403');
assert.match(detail, /rate limit/);
});
test('classic token without repo scope names the scope', () => {
const [state, detail] = verdict(probe({ scopes: ['public_repo'] }));
assert.equal(state, 'missing-scope');
assert.match(detail, /public_repo/);
});
test('no scopes at all is still a classic token', () => {
const [state, detail] = verdict(probe({ scopes: [] }));
assert.equal(state, 'missing-scope');
assert.match(detail, /no scopes at all/);
});
test('missing scope header means a fine-grained token', () => {
assert.equal(
verdict(probe({ scopes: null, token_kind: 'fine-grained PAT' }))[0],
'repository-not-granted');
});
test('app token outside the installation is its own state', () => {
assert.equal(
verdict(probe({ token_kind: 'App installation token', scopes: null,
in_installation: false }))[0],
'not-in-installation');
});
test('app token inside the installation points at metadata', () => {
const [state, detail] = verdict(probe({
token_kind: 'App installation token', scopes: null, in_installation: true }));
assert.equal(state, 'metadata-permission');
assert.match(detail, /Metadata/);
});
test('the indistinguishable case stays indistinguishable', () => {
const [state, detail] = verdict(probe());
assert.equal(state, 'no-access-or-gone');
assert.match(detail, /same 404/);
});
FAQ
Why does GitHub return 404 instead of 403 for a private repository?
To avoid confirming that the repository exists. A 403 would tell an unauthorized caller that a particular private repository is real, which for a private repository is itself the information being protected. The API therefore answers identically whether the resource is absent or merely out of reach.
How do I tell a missing scope from a missing repository?
Read x-oauth-scopes on any authenticated response. If a classic token carries public_repo and not repo, private repositories will 404 for it no matter how correct the name is. If the header is absent entirely the token is fine-grained or an App installation token, neither of which uses scopes, and the question becomes which repositories that credential was granted.
The token works for some repositories and 404s for others. Is that not proof it is a permissions problem?
Not on its own. An expired or revoked token produces exactly that pattern: public repositories still answer 200 because anonymous access covers them, and every private one 404s. Check GET /user first; a 401 there means the per-repository theory is a coincidence.
My GitHub App is installed on the organization but the repository still 404s. Why?
Two different causes, and GET /installation/repositories separates them. If the repository is absent from that list, the installation uses selected repositories and this one was never added. If it is present, the App is missing Metadata: Read, which every repository endpoint requires and which is easy to leave off when picking permissions.
Can the script ever tell me the repository was deleted?
No, and neither can any other read-only caller. Once the token is alive and carries the right scope, no-grant and genuinely-deleted are the same 404 with the same body and the same headers. The script says so rather than picking one, because a confident wrong answer here costs more than an honest ambiguous one.
Related field notes
- Resource not accessible by integration
- An installation that covers only some repositories
- Org lists that 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.
- Troubleshooting the REST API — GitHub Docs
- Authenticating to the REST API — GitHub Docs
- Managing your personal access tokens — GitHub Docs
- Permissions required for GitHub Apps — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.