Skip to content

Diagnostic GitHub API

the endpoint accepts a scope your token was never given

Nineteen calls work. The twentieth returns 403 {"message": "Must have admin rights to Repository."}, or worse, a bare 404 on a repository that is open in a browser tab beside you. The token is valid, it is not expired, it has not been revoked, and it is missing exactly one word from the list it was created with.

Read-only token Python and Node.js Tests included
Two women in uniform standing near a car
Photo by blue sky on Unsplash
The short answer

Read two headers off the response that refused you. x-oauth-scopes is what the token holds. x-accepted-oauth-scopes is what that endpoint accepts. The difference is the repair, and GitHub puts both of them on the same response, including the 404 ones.

The subtlety is that the second header is a list of alternatives, not a list of requirements: admin:repo_hook, write:repo_hook means either will do. Scopes also imply other scopes, so a token holding repo already holds public_repo and does not need it added. The script below does that arithmetic and names the single narrowest scope that would have made the call succeed, rather than telling you to tick repo and move on.

The problem in plain words

The refusal does not look like a permissions problem, which is most of why it takes an afternoon. On a public resource you get a 403 with prose about admin rights, which sends people to the repository settings page to check who is an admin. On a private one you get a 404, and everybody spends twenty minutes on spelling, owner names and letter case before anyone considers that the resource is being hidden rather than missing.

Then the fix overshoots. Someone re-mints the token with every box ticked, the call works, and the incident closes with a credential that can now delete repositories in order to list webhooks. That is a second problem, created by the repair for the first one, and it is invisible because nothing about it fails.

And the header pair is genuinely easy to miss. Almost nobody logs response headers on a failure, so the one place GitHub names the answer is the one place the client throws away. The body carries a sentence of prose; the headers carry the scope.

Token mintedfastpublic_repo,read:orgNineteen callsworkreads look fineHook list 403sprose about adminrightsHeadersdiscardedthe answer was onthemEvery boxtickedwidest token winsand the next ticket widens it again
The one response that names the answer is the one response nobody keeps, because almost no client logs headers on a failure.

Why it happens

Scopes are fixed at creation. A classic personal access token or an OAuth token carries the scope set it was minted with, forever. There is no widening: adding a scope means a new token and a new deployment of it everywhere the old one lives. That is why the narrowest sufficient scope matters more here than it looks — you get one shot at choosing per rotation.

The accepted list is a disjunction. x-accepted-oauth-scopes: admin:repo_hook, write:repo_hook is not two requirements, it is two ways to satisfy one. Reading it as a conjunction is how a token ends up with the administrative variant of a scope it only needed the write variant of.

Scopes imply narrower scopes. repo covers public_repo, repo:status, repo_deployment and security_events. admin:org covers write:org which covers read:org. A naive set difference against x-oauth-scopes will therefore report scopes as missing that the token demonstrably already has, and the person reading the report stops trusting it.

An empty accepted header is a different finding. x-accepted-oauth-scopes: with nothing after it means the endpoint accepts any authenticated token. If that call still failed, no scope will fix it and you are looking at the wrong cause — an SSO authorization, an installation that does not include the repository, or simply no access grant at all.

Fine-grained tokens do not do scopes at all. They carry per-repository permissions instead, and x-oauth-scopes is absent rather than empty on their responses. If the header is not there, this note does not apply to your credential and the permissions model is the one to read.

The fix, as a flow

Two requests, and the second one is the call that was already failing. Everything after that is arithmetic on two header values: close the held set over the scopes it implies, read the accepted list as alternatives rather than as requirements, and report the one that adds the least. The output is a single scope name, which is the only form of this answer anybody acts on correctly.

Two headers, one responseheld against acceptedMissing, with alternativesadd the narrowest one, not repoAccepted list is emptyany token works, so not scopesNo scope header at allpermissions model, not scopesHeld already covers itlook at SSO or installation
Held scopes imply narrower ones and the accepted list is a disjunction, so both sides have to be expanded before they are subtracted.

How to fix it

Make the failing call again and keep the response headers

Not GET /user, and not a call you expect to work: the exact path that was refused. Both headers ride on the failure, including on a 404, and the accepted list is per-endpoint, so a header pair collected somewhere else answers a different question than the one you asked.

Read x-oauth-scopes, and tell absent apart from empty

Absent means the credential is not scope-based at all: a fine-grained token, an App installation token, or no credential. Present but empty means a scope-based token that was minted with none. Those are three different repairs and one of them is not on this page.

Expand what the token holds before you diff

repo implies half a dozen narrower scopes and admin:org implies two. Expand the held set through the implication table first, or the report will name scopes as missing that the call would already have accepted.

Treat the accepted list as alternatives and pick the narrowest

Split x-accepted-oauth-scopes on commas, work out what each alternative is missing, and choose the one that adds the least. When both repo and public_repo would satisfy an endpoint, the answer is public_repo unless the resource is private.

Mint a new token with exactly that scope added

Then deploy it everywhere the old one is and revoke the old one. If you use the GitHub CLI for a user-level credential, gh auth refresh -h github.com -s SCOPE adds one scope to the existing authorization; for a token in a secret store there is no shortcut, it is a new token.

How to check it worked

Re-run against the same path with the new credential. The state should move from missing-scope to scope-satisfied, and the call itself should stop being refused.

python3 github_scope_diff.py --path /repos/acme/api/hooks
# held: public_repo, read:org
# accepted: admin:repo_hook, write:repo_hook
# missing-scope: add write:repo_hook (narrowest of 2 alternatives) and the
# call succeeds; admin:repo_hook would also work and grants more

The full code

Two GETs, one of them the call that failed. Everything that produces a finding is text: a parser that tells an absent header apart from an empty one, an implication closure so a held repo is not reported as missing public_repo, an alternatives solver over the accepted list, and a ranking that prefers the narrowest scope that works. All four are pure, so the tests can pose header pairs that would take a dozen tokens to reproduce for real.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 101 GitHub API fixes, free and open source.
github_scope_diff.py
"""Name the narrowest scope that would have made a refused GitHub call succeed.

Read only. Both requests are GETs, and one of them is the call you are already
making. Nothing here mints, rotates or revokes anything: the repair is printed
for you to run.

The two headers that matter ride on the same response:

    x-oauth-scopes:          what this token holds
    x-accepted-oauth-scopes: what this endpoint accepts, as alternatives

Scope satisfaction is not set membership. The accepted list is a disjunction,
and held scopes imply narrower ones, so the diff has to be computed rather than
eyeballed.
"""
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_scope_diff")

API = "https://api.github.com"
UA = "github-scope-diff/1.0"

# Holding the key already grants everything in the value, transitively. Only the
# implications that change a diff are listed; a scope absent from this table
# implies nothing, which is the common case.
IMPLIES = {
    "repo": ["public_repo", "repo:status", "repo_deployment", "repo:invite",
             "security_events"],
    "admin:org": ["write:org"],
    "write:org": ["read:org"],
    "admin:repo_hook": ["write:repo_hook"],
    "write:repo_hook": ["read:repo_hook"],
    "admin:org_hook": [],
    "admin:public_key": ["write:public_key"],
    "write:public_key": ["read:public_key"],
    "admin:gpg_key": ["write:gpg_key"],
    "write:gpg_key": ["read:gpg_key"],
    "user": ["read:user", "user:email", "user:follow"],
    "write:packages": ["read:packages"],
    "write:discussion": ["read:discussion"],
    "project": ["read:project"],
}

# Lower is narrower. Used only to break ties between alternatives that would
# both work, so the report recommends public_repo over repo rather than the one
# that happened to be listed first.
RANK = {
    "read:org": 10, "read:user": 10, "read:packages": 10, "read:project": 10,
    "read:discussion": 10, "read:repo_hook": 10, "repo:status": 12,
    "user:email": 12, "repo_deployment": 15, "security_events": 18,
    "public_repo": 20, "write:org": 30, "write:repo_hook": 30,
    "write:packages": 30, "write:discussion": 30, "gist": 25, "notifications": 25,
    "admin:repo_hook": 40, "admin:org_hook": 45, "workflow": 55, "repo": 60,
    "user": 60, "admin:org": 70, "delete_repo": 80, "delete:packages": 80,
    "site_admin": 95,
}
DEFAULT_RANK = 50


def parse_scopes(value):
    """Parse an x-oauth-scopes header value. Pure.

    Returns None when the header was absent and a list when it was present, so
    "this credential does not use scopes" stays distinguishable from "this
    credential was minted with none". They have different repairs and only one
    of them is on this page.
    """
    if value is None:
        return None
    return [s.strip() for s in str(value).split(",") if s.strip()]


def expand(scopes):
    """Close a held scope set over the implication table. Pure.

    Without this, a token holding repo is reported as missing public_repo, the
    reader tries it, nothing changes, and the report stops being believed.
    """
    seen, queue = set(), list(scopes or [])
    while queue:
        scope = queue.pop()
        if scope in seen:
            continue
        seen.add(scope)
        queue.extend(IMPLIES.get(scope, ()))
    return seen


def alternatives(value):
    """Parse x-accepted-oauth-scopes into alternative requirement sets. Pure.

    Commas separate alternatives, any one of which satisfies the endpoint.
    Whitespace inside one alternative is treated as a conjunction, which is
    defensive rather than common. None means the header was absent; an empty
    list means it was present and empty, which is the endpoint saying it accepts
    any authenticated caller.
    """
    if value is None:
        return None
    out = []
    for item in str(value).split(","):
        parts = sorted({p for p in item.replace(" and ", " ").split() if p})
        if parts:
            out.append(tuple(parts))
    return out


def satisfies(held, accepted):
    """Decide whether held scopes satisfy an accepted list. Pure.

    Returns (ok, options). ok is None when the endpoint named no scopes at all.
    options lists what each unmet alternative is missing, narrowest first, so
    the caller can recommend the cheapest one rather than the first one.
    """
    if accepted is None:
        return None, []
    if not accepted:
        return True, []
    have = expand(held or [])
    options = []
    for alt in accepted:
        missing = tuple(s for s in alt if s not in have)
        if not missing:
            return True, []
        options.append(missing)
    options.sort(key=lambda m: (len(m),
                                sum(RANK.get(s, DEFAULT_RANK) for s in m), m))
    return False, options


def verdict(status, held, accepted):
    """Turn a status code and a header pair into a finding. Pure."""
    if held is None:
        return ("not-a-scoped-credential",
                "the response carried no x-oauth-scopes header, so this is a "
                "fine-grained token, an App installation token or no credential "
                "at all. None of those use scopes; they use per-resource "
                "permissions, and the missing one is named by "
                "x-accepted-github-permissions instead.")
    if status < 400:
        return ("call-succeeded",
                "the call returned %d, so there is nothing to diff. Held: %s"
                % (status, ", ".join(held) or "none"))

    ok, options = satisfies(held, accepted)
    if ok is None:
        return ("endpoint-named-no-scopes",
                "the %d response carried no x-accepted-oauth-scopes header, so "
                "the endpoint did not name a scope. Scope is not the cause "
                "here; look at SSO authorization, App installation coverage or "
                "plain lack of access." % status)
    if ok and not accepted:
        return ("any-token-accepted",
                "x-accepted-oauth-scopes was present and empty, which means the "
                "endpoint accepts any authenticated token. The %d is therefore "
                "not about scopes and no scope will fix it." % status)
    if ok:
        return ("scope-satisfied",
                "the token already satisfies %s, so the %d has another cause. "
                "Held: %s" % (" or ".join("+".join(a) for a in accepted),
                              status, ", ".join(held) or "none"))

    cheapest = options[0]
    return ("missing-scope",
            "add %s (narrowest of %d alternative(s)) and the call succeeds. "
            "Held: %s. Accepted: %s"
            % ("+".join(cheapest), len(options), ", ".join(held) or "none",
               " or ".join("+".join(a) for a in accepted)))


def get(session, path):
    """One GET. Returns (status, json-or-None, lowercased headers)."""
    url = API + path if path.startswith("/") else path
    r = session.get(url, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = None
    return r.status_code, body, {k.lower(): v for k, v in r.headers.items()}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--path", default="/user",
                    help="the API path that was refused, for example "
                         "/repos/OWNER/REPO/hooks")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN. An anonymous request carries no "
                  "x-oauth-scopes header at all, so there is nothing to diff")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    # GET /user is the cheapest place to read what the token holds, and it
    # answers even when the failing path 404s without headers.
    base_status, base_body, base_headers = get(session, "/user")
    held = parse_scopes(base_headers.get("x-oauth-scopes"))
    if base_status == 200 and isinstance(base_body, dict):
        log.info("authenticated as %s", base_body.get("login", "an unnamed user"))
    elif base_status == 401:
        log.error("GET /user returned 401, so the credential is rejected "
                  "outright. That is a different problem from a narrow one")
        return 2

    status, _, headers = get(session, args.path)
    # The failing response is the authoritative one for both headers; fall back
    # to the /user reading only where the failure omitted them.
    held = parse_scopes(headers.get("x-oauth-scopes")) or held
    accepted = alternatives(headers.get("x-accepted-oauth-scopes"))
    log.info("%s returned %d", args.path, status)
    log.info("held:     %s", ", ".join(held or []) if held is not None
             else "header absent, not a scoped credential")
    log.info("accepted: %s", headers.get("x-accepted-oauth-scopes",
                                         "header absent"))

    state, detail = verdict(status, held, accepted)
    log.info("%s: %s", state, detail)

    if state == "missing-scope":
        _, options = satisfies(held, accepted)
        want = "+".join(options[0])
        log.info("repair: mint a replacement token that adds %s, deploy it, "
                 "then revoke the old one. Scopes cannot be widened in place.",
                 want)
        log.info("repair: for a gh CLI credential, gh auth refresh -h "
                 "github.com -s %s", options[0][0])
    if state == "not-a-scoped-credential":
        log.info("repair: read x-accepted-github-permissions on the same "
                 "response and add that permission to the App or the "
                 "fine-grained token instead.")

    print(json.dumps({"path": args.path, "status": status, "held": held,
                      "accepted": accepted, "state": state}, indent=2))
    return 1 if state in ("missing-scope", "not-a-scoped-credential") else 0


if __name__ == "__main__":
    sys.exit(main())
github-scope-diff.mjs
/**
 * Name the narrowest scope that would have made a refused GitHub call succeed.
 *
 * Read only. Both requests are GETs, and one of them is the call you are
 * already making. The repair is printed, never applied.
 *
 * x-oauth-scopes is what the token holds; x-accepted-oauth-scopes is what the
 * endpoint accepts, as alternatives. Held scopes imply narrower ones, so the
 * diff is computed rather than eyeballed.
 */
const API = 'https://api.github.com';
const UA = 'github-scope-diff/1.0';

/** Holding the key already grants everything in the value, transitively. */
export const IMPLIES = {
  repo: ['public_repo', 'repo:status', 'repo_deployment', 'repo:invite',
    'security_events'],
  'admin:org': ['write:org'],
  'write:org': ['read:org'],
  'admin:repo_hook': ['write:repo_hook'],
  'write:repo_hook': ['read:repo_hook'],
  'admin:org_hook': [],
  'admin:public_key': ['write:public_key'],
  'write:public_key': ['read:public_key'],
  'admin:gpg_key': ['write:gpg_key'],
  'write:gpg_key': ['read:gpg_key'],
  user: ['read:user', 'user:email', 'user:follow'],
  'write:packages': ['read:packages'],
  'write:discussion': ['read:discussion'],
  project: ['read:project'],
};

/** Lower is narrower. Only used to break ties between workable alternatives. */
export const RANK = {
  'read:org': 10, 'read:user': 10, 'read:packages': 10, 'read:project': 10,
  'read:discussion': 10, 'read:repo_hook': 10, 'repo:status': 12,
  'user:email': 12, repo_deployment: 15, security_events: 18,
  public_repo: 20, 'write:org': 30, 'write:repo_hook': 30,
  'write:packages': 30, 'write:discussion': 30, gist: 25, notifications: 25,
  'admin:repo_hook': 40, 'admin:org_hook': 45, workflow: 55, repo: 60,
  user: 60, 'admin:org': 70, delete_repo: 80, 'delete:packages': 80,
  site_admin: 95,
};
export const DEFAULT_RANK = 50;

/**
 * Parse an x-oauth-scopes header value. Pure.
 * null for an absent header, [] for a present but empty one: "does not use
 * scopes" and "was minted with none" are different findings.
 */
export function parseScopes(value) {
  if (value === null || value === undefined) return null;
  return String(value).split(',').map((s) => s.trim()).filter(Boolean);
}

/** Close a held scope set over the implication table. Pure. */
export function expand(scopes) {
  const seen = new Set();
  const queue = [...(scopes ?? [])];
  while (queue.length) {
    const scope = queue.pop();
    if (seen.has(scope)) continue;
    seen.add(scope);
    queue.push(...(IMPLIES[scope] ?? []));
  }
  return seen;
}

/**
 * Parse x-accepted-oauth-scopes into alternative requirement sets. Pure.
 * null for an absent header, [] for a present but empty one, which is the
 * endpoint saying it accepts any authenticated caller.
 */
export function alternatives(value) {
  if (value === null || value === undefined) return null;
  const out = [];
  for (const item of String(value).split(',')) {
    const parts = [...new Set(item.replace(/ and /g, ' ').split(/\s+/)
      .filter(Boolean))].sort();
    if (parts.length) out.push(parts);
  }
  return out;
}

/**
 * Decide whether held scopes satisfy an accepted list. Pure.
 * Returns [ok, options]; ok is null when the endpoint named no scopes.
 */
export function satisfies(held, accepted) {
  if (accepted === null || accepted === undefined) return [null, []];
  if (!accepted.length) return [true, []];
  const have = expand(held ?? []);
  const options = [];
  for (const alt of accepted) {
    const missing = alt.filter((s) => !have.has(s));
    if (!missing.length) return [true, []];
    options.push(missing);
  }
  const cost = (m) => m.reduce((n, s) => n + (RANK[s] ?? DEFAULT_RANK), 0);
  options.sort((a, b) => (a.length - b.length) || (cost(a) - cost(b)) ||
    a.join().localeCompare(b.join()));
  return [false, options];
}

/** Turn a status code and a header pair into a finding. Pure. */
export function verdict(status, held, accepted) {
  if (held === null || held === undefined) {
    return ['not-a-scoped-credential',
      'the response carried no x-oauth-scopes header, so this is a ' +
      'fine-grained token, an App installation token or no credential at all. ' +
      'None of those use scopes; they use per-resource permissions, and the ' +
      'missing one is named by x-accepted-github-permissions instead.'];
  }
  if (status < 400) {
    return ['call-succeeded',
      `the call returned ${status}, so there is nothing to diff. Held: ` +
      `${held.join(', ') || 'none'}`];
  }

  const [ok, options] = satisfies(held, accepted);
  if (ok === null) {
    return ['endpoint-named-no-scopes',
      `the ${status} response carried no x-accepted-oauth-scopes header, so ` +
      'the endpoint did not name a scope. Scope is not the cause here; look ' +
      'at SSO authorization, App installation coverage or plain lack of access.'];
  }
  if (ok && !accepted.length) {
    return ['any-token-accepted',
      'x-accepted-oauth-scopes was present and empty, which means the ' +
      `endpoint accepts any authenticated token. The ${status} is therefore ` +
      'not about scopes and no scope will fix it.'];
  }
  if (ok) {
    return ['scope-satisfied',
      `the token already satisfies ${accepted.map((a) => a.join('+')).join(' or ')}, ` +
      `so the ${status} has another cause. Held: ${held.join(', ') || 'none'}`];
  }
  const cheapest = options[0];
  return ['missing-scope',
    `add ${cheapest.join('+')} (narrowest of ${options.length} alternative(s)) ` +
    `and the call succeeds. Held: ${held.join(', ') || 'none'}. ` +
    `Accepted: ${accepted.map((a) => a.join('+')).join(' or ')}`];
}

async function get(token, path) {
  const url = path.startsWith('/') ? API + path : path;
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  const headers = {};
  for (const [k, v] of res.headers.entries()) headers[k.toLowerCase()] = v;
  return { status: res.status, body, headers };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN. An anonymous request carries no ' +
      'x-oauth-scopes header at all, so there is nothing to diff');
    process.exitCode = 2;
    return;
  }
  const path = process.argv[2] ?? '/user';

  const base = await get(token, '/user');
  let held = parseScopes(base.headers['x-oauth-scopes'] ?? null);
  if (base.status === 200 && base.body) {
    console.log(`authenticated as ${base.body.login ?? 'an unnamed user'}`);
  } else if (base.status === 401) {
    console.error('GET /user returned 401, so the credential is rejected ' +
      'outright. That is a different problem from a narrow one');
    process.exitCode = 2;
    return;
  }

  const failing = await get(token, path);
  const onFailure = parseScopes(failing.headers['x-oauth-scopes'] ?? null);
  if (onFailure && onFailure.length) held = onFailure;
  const accepted = alternatives(failing.headers['x-accepted-oauth-scopes'] ?? null);

  console.log(`${path} returned ${failing.status}`);
  console.log(`held:     ${held === null ? 'header absent, not a scoped credential'
    : held.join(', ')}`);
  console.log(`accepted: ${failing.headers['x-accepted-oauth-scopes'] ?? 'header absent'}`);

  const [state, detail] = verdict(failing.status, held, accepted);
  console.log(`${state}: ${detail}`);

  if (state === 'missing-scope') {
    const [, options] = satisfies(held, accepted);
    console.log(`repair: mint a replacement token that adds ${options[0].join('+')}, ` +
      'deploy it, then revoke the old one. Scopes cannot be widened in place.');
    console.log('repair: for a gh CLI credential, gh auth refresh -h github.com ' +
      `-s ${options[0][0]}`);
  }
  if (state === 'not-a-scoped-credential') {
    console.log('repair: read x-accepted-github-permissions on the same ' +
      'response and add that permission to the App or the fine-grained token.');
  }

  console.log(JSON.stringify({ path, status: failing.status, held, accepted, state }, null, 2));
  process.exitCode = (state === 'missing-scope' ||
    state === 'not-a-scoped-credential') ? 1 : 0;
}

// Only run when invoked directly, so importing this module from the test file
// does not execute main(), fail on the missing token and set an exit code that
// fails the suite 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

The cases worth pinning down are the ones a live token cannot easily produce: a header that is absent versus one that is present and empty, an accepted list where two alternatives both work and the narrower one has to win, and a token holding repo that must not be told to add public_repo. Every function takes strings and returns data, so each of those is three lines.

test_github_scope_diff.py
from github_scope_diff import alternatives, expand, parse_scopes, satisfies, verdict


def test_an_absent_header_is_not_an_empty_scope_list():
    assert parse_scopes(None) is None
    assert parse_scopes("") == []
    assert parse_scopes("repo, read:org") == ["repo", "read:org"]


def test_holding_repo_already_holds_the_narrower_repo_scopes():
    have = expand(["repo"])
    assert "public_repo" in have
    assert "repo:status" in have
    assert "security_events" in have


def test_implication_is_transitive():
    have = expand(["admin:org"])
    assert "write:org" in have
    assert "read:org" in have


def test_expanding_nothing_is_empty_rather_than_an_error():
    assert expand(None) == set()
    assert expand([]) == set()


def test_the_accepted_header_is_parsed_as_alternatives():
    assert alternatives("admin:repo_hook, write:repo_hook") == [
        ("admin:repo_hook",), ("write:repo_hook",)]


def test_an_absent_accepted_header_is_not_an_empty_one():
    assert alternatives(None) is None
    assert alternatives("") == []


def test_a_token_holding_repo_does_not_need_public_repo_added():
    ok, options = satisfies(["repo"], alternatives("public_repo"))
    assert ok is True
    assert options == []


def test_the_narrowest_workable_alternative_wins():
    ok, options = satisfies([], alternatives("repo, public_repo"))
    assert ok is False
    assert options[0] == ("public_repo",)


def test_an_empty_accepted_list_is_satisfied_by_any_token():
    assert satisfies([], []) == (True, [])


def test_an_absent_accepted_list_cannot_be_judged():
    assert satisfies(["repo"], None) == (None, [])


def test_a_missing_scope_is_named_and_the_alternatives_counted():
    state, detail = verdict(403, ["public_repo", "read:org"],
                            alternatives("admin:repo_hook, write:repo_hook"))
    assert state == "missing-scope"
    assert "write:repo_hook" in detail
    assert "2 alternative(s)" in detail


def test_a_fine_grained_credential_is_sent_to_the_other_note():
    state, detail = verdict(403, None, alternatives("repo"))
    assert state == "not-a-scoped-credential"
    assert "x-accepted-github-permissions" in detail


def test_an_empty_accepted_header_rules_scope_out_entirely():
    state, detail = verdict(404, ["repo"], [])
    assert state == "any-token-accepted"
    assert "no scope will fix it" in detail


def test_an_absent_accepted_header_is_its_own_state():
    assert verdict(404, ["repo"], None)[0] == "endpoint-named-no-scopes"


def test_a_satisfied_token_that_still_failed_points_elsewhere():
    state, detail = verdict(404, ["repo"], alternatives("repo"))
    assert state == "scope-satisfied"
    assert "another cause" in detail


def test_a_successful_call_has_nothing_to_diff():
    assert verdict(200, ["repo"], alternatives("repo"))[0] == "call-succeeded"
github-scope-diff.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  alternatives, expand, parseScopes, satisfies, verdict,
} from './github-scope-diff.mjs';

test('an absent header is not an empty scope list', () => {
  assert.equal(parseScopes(null), null);
  assert.deepEqual(parseScopes(''), []);
  assert.deepEqual(parseScopes('repo, read:org'), ['repo', 'read:org']);
});

test('holding repo already holds the narrower repo scopes', () => {
  const have = expand(['repo']);
  assert.ok(have.has('public_repo'));
  assert.ok(have.has('repo:status'));
  assert.ok(have.has('security_events'));
});

test('implication is transitive', () => {
  const have = expand(['admin:org']);
  assert.ok(have.has('write:org'));
  assert.ok(have.has('read:org'));
});

test('expanding nothing is empty rather than an error', () => {
  assert.equal(expand(null).size, 0);
  assert.equal(expand([]).size, 0);
});

test('the accepted header is parsed as alternatives', () => {
  assert.deepEqual(alternatives('admin:repo_hook, write:repo_hook'),
    [['admin:repo_hook'], ['write:repo_hook']]);
});

test('an absent accepted header is not an empty one', () => {
  assert.equal(alternatives(null), null);
  assert.deepEqual(alternatives(''), []);
});

test('a token holding repo does not need public_repo added', () => {
  const [ok, options] = satisfies(['repo'], alternatives('public_repo'));
  assert.equal(ok, true);
  assert.deepEqual(options, []);
});

test('the narrowest workable alternative wins', () => {
  const [ok, options] = satisfies([], alternatives('repo, public_repo'));
  assert.equal(ok, false);
  assert.deepEqual(options[0], ['public_repo']);
});

test('an empty accepted list is satisfied by any token', () => {
  assert.deepEqual(satisfies([], []), [true, []]);
});

test('an absent accepted list cannot be judged', () => {
  assert.deepEqual(satisfies(['repo'], null), [null, []]);
});

test('a missing scope is named and the alternatives counted', () => {
  const [state, detail] = verdict(403, ['public_repo', 'read:org'],
    alternatives('admin:repo_hook, write:repo_hook'));
  assert.equal(state, 'missing-scope');
  assert.match(detail, /write:repo_hook/);
  assert.match(detail, /2 alternative\(s\)/);
});

test('a fine-grained credential is sent to the other note', () => {
  const [state, detail] = verdict(403, null, alternatives('repo'));
  assert.equal(state, 'not-a-scoped-credential');
  assert.match(detail, /x-accepted-github-permissions/);
});

test('an empty accepted header rules scope out entirely', () => {
  const [state, detail] = verdict(404, ['repo'], []);
  assert.equal(state, 'any-token-accepted');
  assert.match(detail, /no scope will fix it/);
});

test('an absent accepted header is its own state', () => {
  assert.equal(verdict(404, ['repo'], null)[0], 'endpoint-named-no-scopes');
});

test('a satisfied token that still failed points elsewhere', () => {
  const [state, detail] = verdict(404, ['repo'], alternatives('repo'));
  assert.equal(state, 'scope-satisfied');
  assert.match(detail, /another cause/);
});

test('a successful call has nothing to diff', () => {
  assert.equal(verdict(200, ['repo'], alternatives('repo'))[0], 'call-succeeded');
});

FAQ

Why do I get 404 instead of 403 when a scope is missing?

Because a 403 on a private resource would confirm that the resource exists, and GitHub will not do that for a caller who is not allowed to know. So a token without repo asking about a private repository is told Not Found, exactly as it would be for a repository that was never created. The scope headers are still on that 404, which is what makes it diagnosable at all; the fuller triage of everything a 404 can be hiding is in the note on a permission error disguised as 404 Not Found.

Is x-accepted-oauth-scopes a list of scopes I need, or scopes that would work?

Scopes that would work, any one of them. It is a disjunction. Reading admin:repo_hook, write:repo_hook as a requirement for both is how tokens end up with administrative rights they never needed: the write variant alone satisfies the endpoint. When more than one alternative is listed, take the narrowest, which for repository scopes usually means preferring public_repo to repo and the write: variant to the admin: one.

My token already has repo. Why does the report still say a scope is missing?

Then the endpoint wants something outside the repo family. repo covers repository contents, statuses, deployments and invitations, but it does not cover organization administration, webhooks at the organization level, package publishing, or workflow files. Those are separate scopes: admin:org, admin:org_hook, write:packages, workflow. The accepted header names which one, and none of them are implied by repo however broad it feels.

Can I add a scope to a token that already exists?

Not for a classic personal access token or an OAuth token: the scope set is fixed at creation, so a wider token is a new token, a new deployment and a revocation of the old one. The gh CLI is the one partial exception for a user-level credential, where gh auth refresh -s SCOPE re-runs the authorization with an extra scope. Fine-grained tokens and GitHub Apps are different again: their permissions can be edited in place, though an App's new permissions stay inert until each installation accepts them.

The response has no x-oauth-scopes header at all. What does that mean?

That the credential is not scope-based. Fine-grained personal access tokens and GitHub App installation tokens carry per-repository, per-resource permissions instead, and they never emit that header, so an absent header is a statement about the credential type rather than about its breadth. For those, the header to read is x-accepted-github-permissions on the same response, and the repair is a permission rather than a scope.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.