Skip to content

Diagnostic GitHub API

403 Resource not accessible by personal access token

The token was minted this morning with the permissions somebody carefully ticked, it reads the repository perfectly well, and one call comes back 403 {"message": "Resource not accessible by personal access token"}. The obvious next move is to look at what the token holds and compare it against what the endpoint wanted. Half of that is possible. The endpoint will tell you what it wanted. Nothing anywhere will tell you what the token holds.

Read-only token Python and Node.js Tests included
A white and black sign
Photo by sarah b on Unsplash
The short answer

Fine-grained personal access tokens carry per-resource permissions — Issues: Read, Contents: Read, Actions: Read — rather than the coarse scopes a classic token carries. When one of those permissions is missing the response is 403 with the message Resource not accessible by personal access token, naming nothing.

There is exactly one readable half of the diff. The refusing response carries x-accepted-github-permissions, which names the permission and level the endpoint accepts, in the form issues=read. There is no counterpart header for what the token holds: a fine-grained token sends no x-oauth-scopes at all, and that absence is itself the reliable way to tell it apart from a classic token, which always sends the header even when it is empty. So the other half of the diff has to be measured — one cheap read per permission, sorted into granted, refused and ambiguous — and then the missing permission ticked on the token's settings page.

The problem in plain words

Fine-grained tokens are the right thing to be using and this is the tax. A classic token is a blunt instrument you can at least inspect: every response tells you the scopes it carries, so the fix is a subtraction you can do in your head. A fine-grained token is precise, which is the point, and opaque, which is not something anybody mentions while recommending it.

What makes the 403 hard is that the token is obviously working. It authenticates. It reads the repository. Nine calls out of ten succeed. So the first hypothesis is never “this token lacks a permission”, it is that the endpoint is wrong, or the path is wrong, or the resource does not exist, and an hour goes into a call that was refused for a reason the message declined to state. When the same token is used from two services, one of which touches issues and one of which does not, the failure also looks environment-specific, which sends somebody into the deployment config.

The second trap is the settings page. Permissions were ticked when the token was created, and they look right when you go back and read them, because reading a list of permissions does not tell you which endpoint needed which one. Contents: Read feels like it should cover an issue — issues live in the repository — and it does not. And where the resource belongs to an organization rather than a repository, the token may also be waiting on an owner's approval, in which case every permission on the page is granted on paper and none of them is in effect.

Fine grainedtoken mintedpermissions tickedby hand403 on oneendpointnot accessible bythis tokenToken pagerereadthe list stilllooks rightNo scope headerexistsnothing says whatit holdsEndpoint blamedinsteadfor an hourand the same box is ticked again
The token authenticates, reads the repository and refuses one call. The half of the diff you want to read does not exist.

Why it happens

Permissions are not scopes and the header set proves it. A classic token or an OAuth token sends x-oauth-scopes on every authenticated response — sometimes an empty string, but the header is there — and the endpoint answers with x-accepted-oauth-scopes, so both halves of that diff arrive in one response. A fine-grained token sends no x-oauth-scopes header whatsoever. That absence, alongside the github_pat_ prefix, is how you identify what you are holding, and it is the reason this note needs a different technique rather than a different table.

The endpoint still names what it wanted. x-accepted-github-permissions arrives on the refusing response and reads like issues=read or contents=read;pull_requests=write. A comma separates alternatives, any one of which is sufficient; a semicolon joins permissions that are all required together. It is the same header a GitHub App gets, which is why it is worth reading carefully rather than skimming for a word.

The message names the actor, and that is the routing decision. “Resource not accessible by personal access token” is a fine-grained PAT. “Resource not accessible by integration” is a GitHub App installation token and has a different repair, because an App's permissions are readable through GET /app and because adding one requires every installation to accept an upgrade. Confusing the two wastes an afternoon in the wrong settings page, so the script reads the actor out of the message before it does anything else.

What the token holds can only be measured, not read. One cheap read per permission, against a repository you know exists: the repository itself for Metadata, the issues list for Issues, the pulls list for Pull requests, the workflows list for Actions. A 200 proves the permission is granted; a 403 with this message proves it is not. That matrix is the missing half of the diff, and it is worth keeping because it is also the fastest way to answer “what is this token actually for” six months later.

A 404 in that matrix is not a no. GitHub answers 404 rather than 403 for resources a token cannot see at all, so a 404 in the middle of a probe run is ambiguous between a missing permission, a missing repository and a disabled feature. Untangling that is its own note; here the script marks the row ambiguous and refuses to guess, because a matrix that quietly converts 404 into “not granted” will send you to tick a permission that was never the problem.

Through GraphQL the same refusal arrives with nothing attached. There is no status code to read — it is a 200 — and no x-accepted-github-permissions header on the response at all. The refusal is an entry in errors with a message that mentions the personal access token and a path pointing at the field. That is enough to know which field was refused and not enough to know which permission it wanted, so the technique is to find the REST endpoint that returns the same object and read the header off its refusal. The script does both in one run so the correspondence is visible.

An organization-level refusal may not be about permissions at all. If every repository probe passes and only organization resources refuse, the likelier cause is that the token is waiting on an organization owner's approval, or that the organization restricts fine-grained tokens by policy. Ticking more permissions cannot fix either, so the script separates that shape rather than folding it into a permissions verdict.

The fix, as a flow

Two things have to be established and they need different evidence. What the endpoint wanted is a header, so it is parsed carefully: commas separate alternatives and semicolons join requirements, and flattening the two is how somebody grants more than was ever asked for. What the token holds cannot be read anywhere, so it is measured with one cheap request per permission, and the measurement has three outcomes rather than two because a 404 is not a no. The credential itself is identified from a prefix and from a header that is missing, and the same refusal is then shown arriving through GraphQL with no header attached to it at all.

One refusal, then five readsheader read, grants measuredBlames the access tokenx-accepted names the permissionBlames an integrationa GitHub App, another note404 inside the matrixambiguous, and never a noEvery probe grantedthe token holds what it needs
The second row is a different note entirely. The third is left unresolved on purpose, because turning it into a no picks the wrong box.

How to fix it

Identify the credential before diagnosing it

One call to GET /user. The github_pat_ prefix and the complete absence of an x-oauth-scopes header together say fine-grained, and the presence of that header — even empty — says classic. If the answer is classic, this is the wrong note and the script says so and points at the scope diff instead of running a probe matrix that does not apply.

Read x-accepted-github-permissions off the refusal itself

Make the failing call again and read the header. It names the permission and level in fine-grained terms, with commas meaning alternatives and semicolons meaning conjunctions. This is the only authoritative statement of what the endpoint wanted, and it is worth quoting verbatim in the ticket rather than paraphrasing.

Measure what the token holds, one permission at a time

The script runs one cheap read per permission and sorts each into granted, refused or ambiguous. Nothing here is a guess: a 200 is a grant, a 403 with this message is a refusal, and a 404 is neither, because a 404 can mean a resource that is not there. Five requests against a core budget of 5,000 an hour.

See the same refusal arrive through GraphQL with no header

One query, one point. The refusal comes back as a 200 with an errors entry naming the personal access token and a path to the field, and with no x-accepted-github-permissions anywhere on the response. The script pairs it with the REST refusal so the field and the permission line up, which is the practical technique for diagnosing a GraphQL-only integration.

Tick exactly what the header named, then check for approval

Edit the token's repository permissions to add the permission by name and no more. If the refusals are only on organization resources while every repository probe passes, stop and check whether the token is pending an owner's approval, because no permission you tick will take effect until it is granted.

How to check it worked

Once the missing permission is added, the probe matrix answers the question the 403 would not.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_fine_grained_pat_probe.py \
    --repo acme/monorepo
# cost: 6 core request(s) out of 5,000/hour, plus 1 GraphQL point
# credential: fine-grained personal access token
#   prefix github_pat_ and no x-oauth-scopes header on GET /user, which a
#   classic token always sends even when it is empty
#
# metadata       200  granted
# contents       200  granted
# issues         403  refused    x-accepted-github-permissions: issues=read
# pull_requests  200  granted
# actions        404  ambiguous  a 404 can hide a 403; see /github/404-masking-403/
#
# fine-grained-permission-missing: the endpoint accepts issues=read and this
# token does not hold it.
# graphql: HTTP 200, 1 refusal(s) naming the personal access token
#   path=repository.issues  Resource not accessible by personal access token
# through graphql the requirement lives nowhere on this response [...] make the
# equivalent REST call and read it off that refusal instead.
# repair: add issues=read to this token's repository permissions -- exactly
# what x-accepted-github-permissions named, and nothing else.

The full code

Two things are being established and they need different evidence. What the endpoint wants is a header, so parsing it correctly matters: commas are alternatives and semicolons are conjunctions, and a parser that flattens them tells you to grant more than you need. What the token holds cannot be read at all, so it is measured, and the measurement has three outcomes rather than two because a 404 is not a no. Everything except the six requests is pure, including the credential identification, which is a fact about a prefix and a missing header.

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_fine_grained_pat_probe.py
"""Work out which permission a fine-grained token is missing.

Read only. Every call here is a GET except the single GraphQL query, and the
GraphQL endpoint takes its document in the request body, so a read travels by
POST there exactly as a write would; that is a transport detail, not a licence
to write. The document is parsed first and refused if it contains a mutation or
a subscription.

A fine-grained personal access token carries per-resource permissions rather
than scopes. When one is missing the answer is 403 "Resource not accessible by
personal access token", which names nothing. The refusing response does carry
x-accepted-github-permissions, naming what the endpoint accepts, but there is
no header at all for what the token holds: fine-grained tokens send no
x-oauth-scopes, and that absence is how you tell one from a classic token.

So one half of the diff is read and the other half is measured, with one cheap
request per permission. A 200 is a grant and a 403 with this message is a
refusal; a 404 is neither, because GitHub hides 403s behind 404s.

Environment:

    GITHUB_TOKEN    the fine-grained token you are diagnosing
"""
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_fine_grained_pat_probe")

API = "https://api.github.com"
UA = "github-fine-grained-pat-probe/1.0"

POINTS_PER_QUERY = 1

# Token prefixes GitHub documents. Only the prefix is ever printed; the token
# itself comes from the environment and never appears in output.
TOKEN_PREFIXES = [
    ("github_pat_", "fine-grained personal access token"),
    ("ghp_", "classic personal access token"),
    ("gho_", "OAuth user token"),
    ("ghu_", "GitHub App user-to-server token"),
    ("ghs_", "GitHub App installation token"),
    ("ghr_", "GitHub App refresh token"),
]

# One cheap read per fine-grained permission, all of them GETs that return at
# most one item. The permission name is the one shown on the token's settings
# page, so the repair can be followed without translation.
PROBES = [
    ("metadata", "/repos/{owner}/{repo}", "Metadata"),
    ("contents", "/repos/{owner}/{repo}/contents/", "Contents"),
    ("issues", "/repos/{owner}/{repo}/issues?per_page=1", "Issues"),
    ("pull_requests", "/repos/{owner}/{repo}/pulls?per_page=1", "Pull requests"),
    ("actions", "/repos/{owner}/{repo}/actions/workflows?per_page=1", "Actions"),
]

# The GraphQL twin of the issues probe, sent to show the same refusal arriving
# with no header attached to it.
ISSUES_QUERY = (
    "query($owner: String!, $name: String!) {"
    " repository(owner: $owner, name: $name) {"
    " issues(first: 1) { totalCount } } }"
)


def strip_noise(document):
    """Remove GraphQL comments and string literals from a document. Pure."""
    src = str(document or "")
    out = []
    i, n = 0, len(src)
    while i < n:
        ch = src[i]
        if ch == "#":
            while i < n and src[i] != "\n":
                i += 1
            continue
        if src.startswith('"""', i):
            j = src.find('"""', i + 3)
            i = n if j < 0 else j + 3
            out.append(" ")
            continue
        if ch == '"':
            i += 1
            while i < n and src[i] != '"':
                i += 2 if src[i] == "\\" else 1
            i += 1
            out.append(" ")
            continue
        out.append(ch)
        i += 1
    return "".join(out)


def operations(document):
    """The top-level operations in a document, in order. Pure."""
    src = strip_noise(document)
    ops, depth, word, declared = [], 0, "", None
    for ch in src + " ":
        if ch.isalnum() or ch == "_":
            word += ch
            continue
        if word:
            if depth == 0 and word in ("query", "mutation", "subscription", "fragment"):
                declared = word
            word = ""
        if ch == "{":
            if depth == 0:
                ops.append(declared or "query")
                declared = None
            depth += 1
        elif ch == "}":
            depth = max(0, depth - 1)
    return ops


def refusal(document):
    """Why this document will not be sent, or None if it is a read. Pure."""
    ops = operations(document)
    if not ops:
        return "the document contains no operation to send."
    for kind in ("mutation", "subscription"):
        if kind in ops:
            return ("the document contains a %s. This script sends queries only: "
                    "a query is a read, and the section it belongs to promises "
                    "its scripts never write." % kind)
    return None


def token_kind(token):
    """The credential type named by the token's prefix. Pure."""
    text = str(token or "")
    for prefix, label in TOKEN_PREFIXES:
        if text.startswith(prefix):
            return label
    return "unrecognised credential"


def token_prefix(token):
    """The prefix alone, safe to print. Pure."""
    text = str(token or "")
    for prefix, _label in TOKEN_PREFIXES:
        if text.startswith(prefix):
            return prefix
    return "none"


def scope_header_state(headers):
    """Whether x-oauth-scopes arrived, and empty or not. Pure.

    A classic or OAuth token always sends this header, even as an empty string
    when the token holds no scopes. A fine-grained token never sends it at all.
    Present-but-empty and absent therefore mean completely different things and
    are the one signal that separates the two credential families from the
    response alone.
    """
    if not isinstance(headers, dict):
        return "absent"
    for key, value in headers.items():
        if str(key).lower() == "x-oauth-scopes":
            return "present-empty" if str(value).strip() == "" else "present"
    return "absent"


def identify(token, headers):
    """What credential this is, from the prefix and the header. Pure.

    Returns (kind, detail). The two signals agreeing is worth stating, because
    a disagreement usually means the header was captured from a different call
    than the token was used on.
    """
    kind = token_kind(token)
    state = scope_header_state(headers)
    fine_grained = kind.startswith("fine-grained")
    if fine_grained and state == "absent":
        return (kind,
                "prefix %s and no x-oauth-scopes header, which a classic token "
                "always sends even when it is empty." % token_prefix(token))
    if fine_grained:
        return (kind,
                "prefix says fine-grained but an x-oauth-scopes header arrived, "
                "which fine-grained tokens do not send. Check that the header "
                "came from a call made with this token.")
    if state in ("present", "present-empty"):
        return (kind,
                "an x-oauth-scopes header arrived, so this credential carries "
                "scopes rather than fine-grained permissions.")
    return (kind, "no x-oauth-scopes header and no fine-grained prefix.")


def parse_accepted_permissions(value):
    """Parse x-accepted-github-permissions. Pure.

    Returns a list of alternatives, each a list of (permission, level) pairs
    that are required together. A comma separates alternatives, any one of
    which is sufficient; a semicolon joins permissions that are all required.
    Flattening the two is how somebody ends up granting more than the endpoint
    ever asked for.
    """
    out = []
    for alternative in str(value or "").split(","):
        pairs = []
        for clause in alternative.split(";"):
            clause = clause.strip()
            if not clause:
                continue
            name, _, level = clause.partition("=")
            pairs.append((name.strip(), level.strip() or "read"))
        if pairs:
            out.append(pairs)
    return out


def actor_from_message(message):
    """Which credential the refusal blames. Pure.

    The routing decision for the whole note: the same 403 body names the actor,
    and each actor has a different place to go and fix it.
    """
    text = str(message or "").lower()
    if "personal access token" in text:
        return "fine-grained-pat"
    if "by integration" in text:
        return "github-app"
    if "oauth app" in text or "oauth application" in text:
        return "oauth-app"
    return None


def grant_from_probe(status, message):
    """What one probe proves about one permission. Pure.

    Three outcomes, not two. A 404 proves nothing, because GitHub answers 404
    rather than 403 for resources a token cannot see, and a matrix that reads
    404 as "not granted" sends people to tick the wrong box.
    """
    try:
        code = int(status)
    except (TypeError, ValueError):
        return ("error", "no status to read.")
    if 200 <= code < 300:
        return ("granted", "the read succeeded, so this permission is held.")
    if code == 403 and actor_from_message(message) == "fine-grained-pat":
        return ("refused", "403 naming the personal access token, so this "
                           "permission is not held.")
    if code == 403:
        return ("refused-other", "403 that does not name a personal access "
                                 "token. Read the message: another actor or "
                                 "another rule refused this.")
    if code == 404:
        return ("ambiguous", "a 404 can hide a 403; see "
                             "/github/404-masking-403/ before concluding "
                             "anything from this row.")
    if code == 401:
        return ("unauthenticated", "the token itself was rejected, which is a "
                                   "credential problem rather than a "
                                   "permission one.")
    return ("error", "HTTP %s, which is neither a grant nor a refusal." % code)


def classify(status, message, headers, token, org_only=False):
    """Judge one refusal. Pure. Returns (state, detail)."""
    kind, _detail = identify(token, headers)
    actor = actor_from_message(message)
    try:
        code = int(status)
    except (TypeError, ValueError):
        code = 0
    if 200 <= code < 300:
        return ("clean", "this call was not refused.")
    if actor == "github-app":
        return ("not-this-note-app",
                "the message names an integration, so this is a GitHub App "
                "installation token and its permissions are readable through "
                "GET /app.")
    if actor == "oauth-app":
        return ("not-this-note-oauth-app",
                "the message names an OAuth App, so the organization is "
                "restricting the App rather than the token lacking a "
                "permission.")
    if code == 404:
        return ("ambiguous-404",
                "a 404 rather than a 403, which GitHub uses to avoid "
                "confirming that a private resource exists.")
    if actor == "fine-grained-pat" and org_only:
        return ("org-resource-refused",
                "every repository probe passed and only organization resources "
                "were refused, which is more often a pending approval or an "
                "organization token policy than a missing permission.")
    if actor == "fine-grained-pat":
        wanted = parse_accepted_permissions(
            (headers or {}).get("x-accepted-github-permissions", ""))
        named = " or ".join(
            ", ".join("%s=%s" % pair for pair in alternative)
            for alternative in wanted) or "nothing the response named"
        return ("fine-grained-permission-missing",
                "the endpoint accepts %s and this token does not hold it." % named)
    if not kind.startswith("fine-grained"):
        return ("not-this-note-classic",
                "this credential carries scopes rather than fine-grained "
                "permissions, so the two scope headers answer it directly.")
    return ("unclassified",
            "a refusal whose message names no actor. Log it verbatim rather "
            "than guessing which credential was blamed.")


def graphql_pat_refusals(body):
    """Errors in a GraphQL response that blame the personal access token. Pure.

    Returns a list of (path, message). The response carries no
    x-accepted-github-permissions header at all, so this identifies the field
    that was refused and nothing about the permission it wanted.
    """
    if not isinstance(body, dict):
        return []
    out = []
    for err in body.get("errors") or []:
        if not isinstance(err, dict):
            continue
        if actor_from_message(err.get("message")) == "fine-grained-pat":
            path = ".".join(str(p) for p in (err.get("path") or [])) or "(no path)"
            out.append((path, str(err.get("message") or "")))
    return out


def where_the_requirement_lives(protocol):
    """Where to read what the endpoint wanted, per API. Pure."""
    if str(protocol).lower() == "graphql":
        return ("nowhere on this response. GraphQL refusals carry no "
                "x-accepted-github-permissions header, so make the equivalent "
                "REST call and read it off that refusal instead.")
    return ("the x-accepted-github-permissions header on the refusing "
            "response itself.")


def missing_permissions(headers, grants):
    """Permissions the endpoint named that the probes show are not held. Pure."""
    wanted = parse_accepted_permissions(
        (headers or {}).get("x-accepted-github-permissions", ""))
    missing = []
    for alternative in wanted:
        for name, level in alternative:
            if grants.get(name) in ("refused", None):
                missing.append((name, level))
    return missing


def repair(state, headers=None):
    """The sentence a reader has to act on. Pure."""
    if state == "fine-grained-permission-missing":
        wanted = parse_accepted_permissions(
            (headers or {}).get("x-accepted-github-permissions", ""))
        named = ", ".join("%s=%s" % pair
                          for alternative in wanted for pair in alternative)
        return ("add %s to this token's repository permissions -- exactly what "
                "x-accepted-github-permissions named, and nothing else."
                % (named or "the permission the header names"))
    if state == "org-resource-refused":
        return ("check whether an organization owner still has to approve this "
                "token, and whether the organization allows fine-grained "
                "tokens at all. No permission you tick takes effect first.")
    if state == "not-this-note-app":
        return ("see /github/app-permission-missing/ -- an App's permissions "
                "are readable and adding one needs every installation to "
                "accept the upgrade.")
    if state == "not-this-note-classic":
        return ("see /github/missing-oauth-scope/ -- both halves of that diff "
                "arrive as headers on the same response.")
    if state == "ambiguous-404":
        return ("see /github/404-masking-403/ -- decide between missing and "
                "invisible before changing any permission.")
    if state == "clean":
        return ("nothing on this call. Run the probe matrix anyway if you want "
                "to know what the token can reach before it matters.")
    return ("record the status, the message and the "
            "x-accepted-github-permissions header verbatim; between them they "
            "name the actor and the requirement.")


def run_query(session, document, variables):
    """Send one query. Returns (status, body-or-None).

    A GraphQL query is a read; POST is only how the document reaches the
    endpoint, which is why the verb is written here beside the URL rather than
    hidden in a constant where it could be mistaken for a write path.
    """
    r = session.post(API + "/graphql",
                     json={"query": document, "variables": variables or {}},
                     timeout=30)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, None


def message_of(response):
    """The message field of an error body, or an empty string."""
    try:
        return str((response.json() or {}).get("message") or "")
    except ValueError:
        return ""


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", required=True, help="owner/name to probe")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (the token you are diagnosing)")
        return 2
    try:
        owner, repo = args.repo.split("/", 1)
    except ValueError:
        log.error("--repo takes owner/name")
        return 2

    why_not = refusal(ISSUES_QUERY)
    if why_not:
        log.error("refusing to send: %s", why_not)
        return 2

    log.info("cost: %d core request(s) out of 5,000/hour, plus %d GraphQL point",
             len(PROBES) + 1, POINTS_PER_QUERY)

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        # GitHub rejects requests with no User-Agent outright.
        "User-Agent": UA,
    })

    who = session.get(API + "/user", timeout=30)
    kind, detail = identify(token, dict(who.headers))
    log.info("credential: %s", kind)
    log.info("  %s", detail)

    grants, rows = {}, []
    refusal_headers, refusal_message, refusal_status = {}, "", 0
    for name, path, label in PROBES:
        r = session.get(API + path.format(owner=owner, repo=repo), timeout=30)
        msg = message_of(r) if r.status_code >= 400 else ""
        verdict, why = grant_from_probe(r.status_code, msg)
        grants[name] = verdict
        accepted = r.headers.get("x-accepted-github-permissions", "")
        if verdict == "refused" and not refusal_message:
            refusal_headers = dict(r.headers)
            refusal_message, refusal_status = msg, r.status_code
        log.info("%-14s %s  %-10s %s", name, r.status_code, verdict,
                 ("x-accepted-github-permissions: " + accepted) if accepted else why)
        rows.append({"permission": name, "settings_label": label,
                     "status": r.status_code, "verdict": verdict,
                     "accepted": accepted})

    state, why = classify(refusal_status, refusal_message, refusal_headers, token)
    log.info("%s: %s", state, why)
    log.info("the requirement lives in %s", where_the_requirement_lives("rest"))

    status, body = run_query(session, ISSUES_QUERY, {"owner": owner, "name": repo})
    gql = graphql_pat_refusals(body)
    log.info("graphql: HTTP %s, %d refusal(s) naming the personal access token",
             status, len(gql))
    for path, msg in gql:
        log.info("  path=%s  %s", path, msg)
    if gql:
        log.info("through graphql the requirement lives %s",
                 where_the_requirement_lives("graphql"))

    log.info("repair: %s", repair(state, refusal_headers))

    print(json.dumps({
        "credential": kind,
        "prefix": token_prefix(token),
        "scope_header": scope_header_state(dict(who.headers)),
        "probes": rows,
        "missing_permissions": missing_permissions(refusal_headers, grants),
        "graphql_refusals": gql,
        "state": state,
        "detail": why,
        "repair": repair(state, refusal_headers),
    }, indent=2, default=str))
    return 1 if state.startswith(("fine-grained", "org-resource")) else 0


if __name__ == "__main__":
    sys.exit(main())
github-fine-grained-pat-probe.mjs
/**
 * Work out which permission a fine-grained token is missing.
 *
 * Read only. Every call is a GET except the single GraphQL query, and the
 * GraphQL endpoint takes its document in the request body, so a read travels
 * by POST there exactly as a write would. The document is parsed first and
 * refused if it contains a mutation or a subscription.
 *
 * A fine-grained personal access token carries per-resource permissions rather
 * than scopes. The refusing response names what the endpoint accepts in
 * x-accepted-github-permissions, and nothing anywhere names what the token
 * holds: fine-grained tokens send no x-oauth-scopes at all. So one half of the
 * diff is read and the other is measured, one cheap request per permission.
 *
 * Environment:
 *   GITHUB_TOKEN   the fine-grained token you are diagnosing
 *   GITHUB_REPO    owner/name to probe
 */
const API = 'https://api.github.com';
const UA = 'github-fine-grained-pat-probe/1.0';

export const POINTS_PER_QUERY = 1;

/** Token prefixes GitHub documents. Only the prefix is ever printed. */
export const TOKEN_PREFIXES = [
  ['github_pat_', 'fine-grained personal access token'],
  ['ghp_', 'classic personal access token'],
  ['gho_', 'OAuth user token'],
  ['ghu_', 'GitHub App user-to-server token'],
  ['ghs_', 'GitHub App installation token'],
  ['ghr_', 'GitHub App refresh token'],
];

/** One cheap read per fine-grained permission. */
export const PROBES = [
  ['metadata', '/repos/{owner}/{repo}', 'Metadata'],
  ['contents', '/repos/{owner}/{repo}/contents/', 'Contents'],
  ['issues', '/repos/{owner}/{repo}/issues?per_page=1', 'Issues'],
  ['pull_requests', '/repos/{owner}/{repo}/pulls?per_page=1', 'Pull requests'],
  ['actions', '/repos/{owner}/{repo}/actions/workflows?per_page=1', 'Actions'],
];

const ISSUES_QUERY = 'query($owner: String!, $name: String!) {'
  + ' repository(owner: $owner, name: $name) {'
  + ' issues(first: 1) { totalCount } } }';

/** Remove GraphQL comments and string literals from a document. Pure. */
export function stripNoise(document) {
  const src = String(document ?? '');
  const out = [];
  let i = 0;
  while (i < src.length) {
    const ch = src[i];
    if (ch === '#') {
      while (i < src.length && src[i] !== '\n') i += 1;
      continue;
    }
    if (src.startsWith('"""', i)) {
      const j = src.indexOf('"""', i + 3);
      i = j < 0 ? src.length : j + 3;
      out.push(' ');
      continue;
    }
    if (ch === '"') {
      i += 1;
      while (i < src.length && src[i] !== '"') i += src[i] === '\\' ? 2 : 1;
      i += 1;
      out.push(' ');
      continue;
    }
    out.push(ch);
    i += 1;
  }
  return out.join('');
}

/** The top-level operations in a document, in order. Pure. */
export function operations(document) {
  const src = `${stripNoise(document)} `;
  const ops = [];
  let depth = 0;
  let word = '';
  let declared = null;
  for (const ch of src) {
    if (/[A-Za-z0-9_]/.test(ch)) { word += ch; continue; }
    if (word) {
      if (depth === 0 && ['query', 'mutation', 'subscription', 'fragment'].includes(word)) {
        declared = word;
      }
      word = '';
    }
    if (ch === '{') {
      if (depth === 0) { ops.push(declared || 'query'); declared = null; }
      depth += 1;
    } else if (ch === '}') {
      depth = Math.max(0, depth - 1);
    }
  }
  return ops;
}

/** Why this document will not be sent, or null if it is a read. Pure. */
export function refusal(document) {
  const ops = operations(document);
  if (ops.length === 0) return 'the document contains no operation to send.';
  for (const kind of ['mutation', 'subscription']) {
    if (ops.includes(kind)) {
      return `the document contains a ${kind}. This script sends queries only: `
        + 'a query is a read, and the section it belongs to promises its '
        + 'scripts never write.';
    }
  }
  return null;
}

/** The credential type named by the token's prefix. Pure. */
export function tokenKind(token) {
  const text = String(token ?? '');
  for (const [prefix, label] of TOKEN_PREFIXES) {
    if (text.startsWith(prefix)) return label;
  }
  return 'unrecognised credential';
}

/** The prefix alone, safe to print. Pure. */
export function tokenPrefix(token) {
  const text = String(token ?? '');
  for (const [prefix] of TOKEN_PREFIXES) {
    if (text.startsWith(prefix)) return prefix;
  }
  return 'none';
}

/** Whether x-oauth-scopes arrived, and empty or not. Pure. */
export function scopeHeaderState(headers) {
  if (!headers || typeof headers !== 'object') return 'absent';
  for (const [key, value] of Object.entries(headers)) {
    if (String(key).toLowerCase() === 'x-oauth-scopes') {
      return String(value).trim() === '' ? 'present-empty' : 'present';
    }
  }
  return 'absent';
}

/** What credential this is, from the prefix and the header. Pure. */
export function identify(token, headers) {
  const kind = tokenKind(token);
  const state = scopeHeaderState(headers);
  const fineGrained = kind.startsWith('fine-grained');
  if (fineGrained && state === 'absent') {
    return [kind, `prefix ${tokenPrefix(token)} and no x-oauth-scopes header, `
      + 'which a classic token always sends even when it is empty.'];
  }
  if (fineGrained) {
    return [kind, 'prefix says fine-grained but an x-oauth-scopes header '
      + 'arrived, which fine-grained tokens do not send. Check that the header '
      + 'came from a call made with this token.'];
  }
  if (state === 'present' || state === 'present-empty') {
    return [kind, 'an x-oauth-scopes header arrived, so this credential carries '
      + 'scopes rather than fine-grained permissions.'];
  }
  return [kind, 'no x-oauth-scopes header and no fine-grained prefix.'];
}

/** Parse x-accepted-github-permissions into alternatives. Pure. */
export function parseAcceptedPermissions(value) {
  const out = [];
  for (const alternative of String(value ?? '').split(',')) {
    const pairs = [];
    for (const raw of alternative.split(';')) {
      const clause = raw.trim();
      if (!clause) continue;
      const idx = clause.indexOf('=');
      const name = idx < 0 ? clause : clause.slice(0, idx);
      const level = idx < 0 ? '' : clause.slice(idx + 1);
      pairs.push([name.trim(), level.trim() || 'read']);
    }
    if (pairs.length) out.push(pairs);
  }
  return out;
}

/** Which credential the refusal blames. Pure. */
export function actorFromMessage(message) {
  const text = String(message ?? '').toLowerCase();
  if (text.includes('personal access token')) return 'fine-grained-pat';
  if (text.includes('by integration')) return 'github-app';
  if (text.includes('oauth app') || text.includes('oauth application')) return 'oauth-app';
  return null;
}

/** What one probe proves about one permission. Pure. [verdict, why]. */
export function grantFromProbe(status, message) {
  const code = Number(status);
  if (!Number.isFinite(code) || status === null || status === '') {
    return ['error', 'no status to read.'];
  }
  if (code >= 200 && code < 300) {
    return ['granted', 'the read succeeded, so this permission is held.'];
  }
  if (code === 403 && actorFromMessage(message) === 'fine-grained-pat') {
    return ['refused', '403 naming the personal access token, so this '
      + 'permission is not held.'];
  }
  if (code === 403) {
    return ['refused-other', '403 that does not name a personal access token. '
      + 'Read the message: another actor or another rule refused this.'];
  }
  if (code === 404) {
    return ['ambiguous', 'a 404 can hide a 403; see /github/404-masking-403/ '
      + 'before concluding anything from this row.'];
  }
  if (code === 401) {
    return ['unauthenticated', 'the token itself was rejected, which is a '
      + 'credential problem rather than a permission one.'];
  }
  return ['error', `HTTP ${code}, which is neither a grant nor a refusal.`];
}

/** Judge one refusal. Pure. Returns [state, detail]. */
export function classify(status, message, headers, token, orgOnly = false) {
  const [kind] = identify(token, headers);
  const actor = actorFromMessage(message);
  const code = Number(status) || 0;
  if (code >= 200 && code < 300) return ['clean', 'this call was not refused.'];
  if (actor === 'github-app') {
    return ['not-this-note-app', 'the message names an integration, so this is '
      + 'a GitHub App installation token and its permissions are readable '
      + 'through GET /app.'];
  }
  if (actor === 'oauth-app') {
    return ['not-this-note-oauth-app', 'the message names an OAuth App, so the '
      + 'organization is restricting the App rather than the token lacking a '
      + 'permission.'];
  }
  if (code === 404) {
    return ['ambiguous-404', 'a 404 rather than a 403, which GitHub uses to '
      + 'avoid confirming that a private resource exists.'];
  }
  if (actor === 'fine-grained-pat' && orgOnly) {
    return ['org-resource-refused', 'every repository probe passed and only '
      + 'organization resources were refused, which is more often a pending '
      + 'approval or an organization token policy than a missing permission.'];
  }
  if (actor === 'fine-grained-pat') {
    const wanted = parseAcceptedPermissions(
      (headers || {})['x-accepted-github-permissions'] || '');
    const named = wanted
      .map((alt) => alt.map(([n, l]) => `${n}=${l}`).join(', '))
      .join(' or ') || 'nothing the response named';
    return ['fine-grained-permission-missing',
      `the endpoint accepts ${named} and this token does not hold it.`];
  }
  if (!kind.startsWith('fine-grained')) {
    return ['not-this-note-classic', 'this credential carries scopes rather '
      + 'than fine-grained permissions, so the two scope headers answer it '
      + 'directly.'];
  }
  return ['unclassified', 'a refusal whose message names no actor. Log it '
    + 'verbatim rather than guessing which credential was blamed.'];
}

/** Errors in a GraphQL response that blame the personal access token. Pure. */
export function graphqlPatRefusals(body) {
  if (!body || typeof body !== 'object' || !Array.isArray(body.errors)) return [];
  const out = [];
  for (const err of body.errors) {
    if (!err || typeof err !== 'object') continue;
    if (actorFromMessage(err.message) === 'fine-grained-pat') {
      const path = (err.path || []).map((p) => String(p)).join('.') || '(no path)';
      out.push([path, String(err.message ?? '')]);
    }
  }
  return out;
}

/** Where to read what the endpoint wanted, per API. Pure. */
export function whereTheRequirementLives(protocol) {
  if (String(protocol).toLowerCase() === 'graphql') {
    return 'nowhere on this response. GraphQL refusals carry no '
      + 'x-accepted-github-permissions header, so make the equivalent REST call '
      + 'and read it off that refusal instead.';
  }
  return 'the x-accepted-github-permissions header on the refusing response itself.';
}

/** Permissions the endpoint named that the probes show are not held. Pure. */
export function missingPermissions(headers, grants) {
  const wanted = parseAcceptedPermissions(
    (headers || {})['x-accepted-github-permissions'] || '');
  const missing = [];
  for (const alternative of wanted) {
    for (const [name, level] of alternative) {
      const held = (grants || {})[name];
      if (held === 'refused' || held === undefined) missing.push([name, level]);
    }
  }
  return missing;
}

/** The sentence a reader has to act on. Pure. */
export function repair(state, headers = null) {
  if (state === 'fine-grained-permission-missing') {
    const wanted = parseAcceptedPermissions(
      (headers || {})['x-accepted-github-permissions'] || '');
    const named = wanted.flat().map(([n, l]) => `${n}=${l}`).join(', ');
    return `add ${named || 'the permission the header names'} to this token's `
      + 'repository permissions -- exactly what x-accepted-github-permissions '
      + 'named, and nothing else.';
  }
  if (state === 'org-resource-refused') {
    return 'check whether an organization owner still has to approve this '
      + 'token, and whether the organization allows fine-grained tokens at all. '
      + 'No permission you tick takes effect first.';
  }
  if (state === 'not-this-note-app') {
    return 'see /github/app-permission-missing/ -- an App\'s permissions are '
      + 'readable and adding one needs every installation to accept the upgrade.';
  }
  if (state === 'not-this-note-classic') {
    return 'see /github/missing-oauth-scope/ -- both halves of that diff arrive '
      + 'as headers on the same response.';
  }
  if (state === 'ambiguous-404') {
    return 'see /github/404-masking-403/ -- decide between missing and '
      + 'invisible before changing any permission.';
  }
  if (state === 'clean') {
    return 'nothing on this call. Run the probe matrix anyway if you want to '
      + 'know what the token can reach before it matters.';
  }
  return 'record the status, the message and the x-accepted-github-permissions '
    + 'header verbatim; between them they name the actor and the requirement.';
}

function headers(token) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'Content-Type': 'application/json',
    'User-Agent': UA,
  };
}

function headerObject(res) {
  const out = {};
  res.headers.forEach((value, key) => { out[key.toLowerCase()] = value; });
  return out;
}

async function runQuery(token, document, variables) {
  const res = await fetch(`${API}/graphql`, {
    // A GraphQL query is a read. POST is only how the document reaches the
    // endpoint, and refusal() has already rejected anything that is not a read.
    method: 'POST',
    headers: headers(token),
    body: JSON.stringify({ query: document, variables: variables || {} }),
  });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  return { status: res.status, body };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const repo = process.env.GITHUB_REPO;
  if (!token || !repo) {
    console.error('set GITHUB_TOKEN (the token you are diagnosing) and GITHUB_REPO=owner/name');
    process.exitCode = 2;
    return;
  }
  const [owner, name] = repo.split('/');
  const whyNot = refusal(ISSUES_QUERY);
  if (whyNot) {
    console.error(`refusing to send: ${whyNot}`);
    process.exitCode = 2;
    return;
  }
  console.log(`cost: ${PROBES.length + 1} core request(s) out of 5,000/hour, `
    + `plus ${POINTS_PER_QUERY} GraphQL point`);

  const who = await fetch(`${API}/user`, { headers: headers(token) });
  const [kind, detail] = identify(token, headerObject(who));
  console.log(`credential: ${kind}`);
  console.log(`  ${detail}`);

  const grants = {};
  const rows = [];
  let refusalHeaders = {};
  let refusalMessage = '';
  let refusalStatus = 0;
  for (const [permission, path, label] of PROBES) {
    const url = API + path.replace('{owner}', owner).replace('{repo}', name);
    // eslint-disable-next-line no-await-in-loop
    const res = await fetch(url, { headers: headers(token) });
    let message = '';
    if (res.status >= 400) {
      // eslint-disable-next-line no-await-in-loop
      try { message = String(((await res.json()) || {}).message || ''); } catch { message = ''; }
    }
    const [verdict, why] = grantFromProbe(res.status, message);
    grants[permission] = verdict;
    const all = headerObject(res);
    const accepted = all['x-accepted-github-permissions'] || '';
    if (verdict === 'refused' && !refusalMessage) {
      refusalHeaders = all;
      refusalMessage = message;
      refusalStatus = res.status;
    }
    console.log(`${permission.padEnd(14)} ${res.status}  ${verdict.padEnd(10)} `
      + `${accepted ? `x-accepted-github-permissions: ${accepted}` : why}`);
    rows.push({ permission, settings_label: label, status: res.status, verdict, accepted });
  }

  const [state, why] = classify(refusalStatus, refusalMessage, refusalHeaders, token);
  console.log(`${state}: ${why}`);
  console.log(`the requirement lives in ${whereTheRequirementLives('rest')}`);

  const { status, body } = await runQuery(token, ISSUES_QUERY, { owner, name });
  const gql = graphqlPatRefusals(body);
  console.log(`graphql: HTTP ${status}, ${gql.length} refusal(s) naming the `
    + 'personal access token');
  for (const [path, message] of gql) console.log(`  path=${path}  ${message}`);
  if (gql.length) {
    console.log(`through graphql the requirement lives ${whereTheRequirementLives('graphql')}`);
  }
  console.log(`repair: ${repair(state, refusalHeaders)}`);

  console.log(JSON.stringify({
    credential: kind,
    prefix: tokenPrefix(token),
    probes: rows,
    missing_permissions: missingPermissions(refusalHeaders, grants),
    graphql_refusals: gql,
    state,
    detail: why,
  }, null, 2));
  process.exitCode = state.startsWith('fine-grained') || state.startsWith('org-resource') ? 1 : 0;
}

// Guarded so importing this file from the test runner does not start a run and
// fail 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 two assertions that carry the note are about absence. A fine-grained token is identified by the fact that no scope header arrived, and an empty scope header is asserted to mean the opposite of a missing one, because that is the single response-level signal separating the two credential families. After that: the actor read out of the message, which routes three different 403s to three different notes; the permission header parsed with commas as alternatives and semicolons as conjunctions rather than flattened into one list; the three-outcome probe verdict, with a 404 refusing to become a no; and the GraphQL refusal, which is recognisable by its message and carries no header at all. Tokens in the fixtures are obviously fake and short.

test_github_fine_grained_pat_probe.py
from github_fine_grained_pat_probe import (
    actor_from_message, classify, grant_from_probe, graphql_pat_refusals,
    identify, missing_permissions, operations, parse_accepted_permissions,
    refusal, repair, scope_header_state, token_kind, token_prefix,
    where_the_requirement_lives,
)

# Obviously fake, and short enough that nobody could mistake one for a secret.
FG = "github_pat_FAKE"
CLASSIC = "ghp_FAKE"
APP = "ghs_FAKE"

REFUSED = {"x-accepted-github-permissions": "issues=read"}
PAT_403 = "Resource not accessible by personal access token"
APP_403 = "Resource not accessible by integration"


def test_a_fine_grained_token_is_known_by_a_header_that_is_not_there():
    kind, detail = identify(FG, {"x-github-api-version-selected": "2022-11-28"})
    assert kind == "fine-grained personal access token"
    assert "no x-oauth-scopes header" in detail
    assert token_prefix(FG) == "github_pat_"


def test_an_empty_scope_header_is_the_opposite_of_a_missing_one():
    assert scope_header_state({"x-oauth-scopes": ""}) == "present-empty"
    assert scope_header_state({"X-OAuth-Scopes": "repo"}) == "present"
    assert scope_header_state({"x-github-request-id": "abc"}) == "absent"
    assert scope_header_state(None) == "absent"
    # A classic token with no scopes still sends the header, so present-empty
    # identifies a classic token just as firmly as a populated one does.
    kind, _ = identify(CLASSIC, {"x-oauth-scopes": ""})
    assert kind == "classic personal access token"


def test_every_documented_prefix_is_named():
    assert token_kind(FG).startswith("fine-grained")
    assert token_kind(CLASSIC).startswith("classic")
    assert token_kind(APP) == "GitHub App installation token"
    assert token_kind("nonsense") == "unrecognised credential"
    assert token_prefix("nonsense") == "none"


def test_the_message_names_the_actor_and_routes_the_repair():
    assert actor_from_message(PAT_403) == "fine-grained-pat"
    assert actor_from_message(APP_403) == "github-app"
    assert actor_from_message("Although you appear to have the correct "
                              "authorization credentials, the OAuth App is "
                              "restricted") == "oauth-app"
    assert actor_from_message("Not Found") is None


def test_an_app_refusal_is_handed_to_the_app_note():
    state, _ = classify(403, APP_403, {}, APP)
    assert state == "not-this-note-app"
    assert "app-permission-missing" in repair(state)


def test_a_classic_token_is_handed_to_the_scope_note():
    state, _ = classify(403, "Must have admin rights to Repository.",
                        {"x-oauth-scopes": "public_repo"}, CLASSIC)
    assert state == "not-this-note-classic"
    assert "missing-oauth-scope" in repair(state)


def test_the_fine_grained_refusal_names_what_the_endpoint_accepts():
    state, detail = classify(403, PAT_403, REFUSED, FG)
    assert state == "fine-grained-permission-missing"
    assert "issues=read" in detail
    assert "issues=read" in repair(state, REFUSED)


def test_an_organization_only_refusal_is_not_a_missing_permission():
    state, detail = classify(403, PAT_403, REFUSED, FG, org_only=True)
    assert state == "org-resource-refused"
    assert "approval" in detail
    assert "approve this token" in repair(state)


def test_commas_are_alternatives_and_semicolons_are_requirements():
    assert parse_accepted_permissions("issues=read") == [[("issues", "read")]]
    assert parse_accepted_permissions("issues=read,pull_requests=read") == [
        [("issues", "read")], [("pull_requests", "read")]]
    assert parse_accepted_permissions("contents=read;pull_requests=write") == [
        [("contents", "read"), ("pull_requests", "write")]]
    assert parse_accepted_permissions("metadata") == [[("metadata", "read")]]
    assert parse_accepted_permissions("") == []


def test_a_probe_has_three_outcomes_because_a_404_is_not_a_no():
    assert grant_from_probe(200, "")[0] == "granted"
    assert grant_from_probe(403, PAT_403)[0] == "refused"
    assert grant_from_probe(403, APP_403)[0] == "refused-other"
    assert grant_from_probe(401, "Bad credentials")[0] == "unauthenticated"
    verdict, why = grant_from_probe(404, "Not Found")
    assert verdict == "ambiguous"
    assert "404-masking-403" in why
    assert grant_from_probe(None, "")[0] == "error"


def test_a_404_in_the_matrix_is_never_reported_as_a_refusal():
    state, _ = classify(404, "Not Found", {}, FG)
    assert state == "ambiguous-404"
    assert "404-masking-403" in repair(state)


def test_the_missing_permission_is_the_named_one_the_probes_refused():
    grants = {"metadata": "granted", "issues": "refused"}
    assert missing_permissions(REFUSED, grants) == [("issues", "read")]
    assert missing_permissions(REFUSED, {"issues": "granted"}) == []
    assert missing_permissions({}, grants) == []


def test_the_same_refusal_through_graphql_carries_no_header():
    body = {"data": {"repository": None},
            "errors": [{"type": "FORBIDDEN", "path": ["repository", "issues"],
                        "message": PAT_403},
                       {"type": "NOT_FOUND", "message": "Could not resolve"}]}
    found = graphql_pat_refusals(body)
    assert found == [("repository.issues", PAT_403)]
    assert graphql_pat_refusals({"data": {}}) == []
    assert "no x-accepted-github-permissions header" in where_the_requirement_lives("graphql")
    assert "x-accepted-github-permissions header" in where_the_requirement_lives("rest")


def test_the_document_this_script_sends_is_a_read():
    assert operations("query Q { repository(owner: \"a\", name: \"b\") { issues(first: 1) { totalCount } } }") == ["query"]
    assert refusal("mutation M { addStar(input: {}) { clientMutationId } }")
    assert refusal("subscription S { thing { id } }")
    assert refusal("") == "the document contains no operation to send."
github-fine-grained-pat-probe.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  actorFromMessage, classify, grantFromProbe, graphqlPatRefusals, identify,
  missingPermissions, operations, parseAcceptedPermissions, refusal, repair,
  scopeHeaderState, tokenKind, tokenPrefix, whereTheRequirementLives,
} from './github-fine-grained-pat-probe.mjs';

// Obviously fake, and short enough that nobody could mistake one for a secret.
const FG = 'github_pat_FAKE';
const CLASSIC = 'ghp_FAKE';
const APP = 'ghs_FAKE';

const REFUSED = { 'x-accepted-github-permissions': 'issues=read' };
const PAT_403 = 'Resource not accessible by personal access token';
const APP_403 = 'Resource not accessible by integration';

test('a fine grained token is known by a header that is not there', () => {
  const [kind, detail] = identify(FG, { 'x-github-api-version-selected': '2022-11-28' });
  assert.equal(kind, 'fine-grained personal access token');
  assert.match(detail, /no x-oauth-scopes header/);
  assert.equal(tokenPrefix(FG), 'github_pat_');
});

test('an empty scope header is the opposite of a missing one', () => {
  assert.equal(scopeHeaderState({ 'x-oauth-scopes': '' }), 'present-empty');
  assert.equal(scopeHeaderState({ 'X-OAuth-Scopes': 'repo' }), 'present');
  assert.equal(scopeHeaderState({ 'x-github-request-id': 'abc' }), 'absent');
  assert.equal(scopeHeaderState(null), 'absent');
  assert.equal(identify(CLASSIC, { 'x-oauth-scopes': '' })[0],
    'classic personal access token');
});

test('every documented prefix is named', () => {
  assert.ok(tokenKind(FG).startsWith('fine-grained'));
  assert.ok(tokenKind(CLASSIC).startsWith('classic'));
  assert.equal(tokenKind(APP), 'GitHub App installation token');
  assert.equal(tokenKind('nonsense'), 'unrecognised credential');
  assert.equal(tokenPrefix('nonsense'), 'none');
});

test('the message names the actor and routes the repair', () => {
  assert.equal(actorFromMessage(PAT_403), 'fine-grained-pat');
  assert.equal(actorFromMessage(APP_403), 'github-app');
  assert.equal(actorFromMessage('Although you appear to have the correct '
    + 'authorization credentials, the OAuth App is restricted'), 'oauth-app');
  assert.equal(actorFromMessage('Not Found'), null);
});

test('an app refusal is handed to the app note', () => {
  const [state] = classify(403, APP_403, {}, APP);
  assert.equal(state, 'not-this-note-app');
  assert.match(repair(state), /app-permission-missing/);
});

test('a classic token is handed to the scope note', () => {
  const [state] = classify(403, 'Must have admin rights to Repository.',
    { 'x-oauth-scopes': 'public_repo' }, CLASSIC);
  assert.equal(state, 'not-this-note-classic');
  assert.match(repair(state), /missing-oauth-scope/);
});

test('the fine grained refusal names what the endpoint accepts', () => {
  const [state, detail] = classify(403, PAT_403, REFUSED, FG);
  assert.equal(state, 'fine-grained-permission-missing');
  assert.match(detail, /issues=read/);
  assert.match(repair(state, REFUSED), /issues=read/);
});

test('an organization only refusal is not a missing permission', () => {
  const [state, detail] = classify(403, PAT_403, REFUSED, FG, true);
  assert.equal(state, 'org-resource-refused');
  assert.match(detail, /approval/);
  assert.match(repair(state), /approve this token/);
});

test('commas are alternatives and semicolons are requirements', () => {
  assert.deepEqual(parseAcceptedPermissions('issues=read'), [[['issues', 'read']]]);
  assert.deepEqual(parseAcceptedPermissions('issues=read,pull_requests=read'),
    [[['issues', 'read']], [['pull_requests', 'read']]]);
  assert.deepEqual(parseAcceptedPermissions('contents=read;pull_requests=write'),
    [[['contents', 'read'], ['pull_requests', 'write']]]);
  assert.deepEqual(parseAcceptedPermissions('metadata'), [[['metadata', 'read']]]);
  assert.deepEqual(parseAcceptedPermissions(''), []);
});

test('a probe has three outcomes because a 404 is not a no', () => {
  assert.equal(grantFromProbe(200, '')[0], 'granted');
  assert.equal(grantFromProbe(403, PAT_403)[0], 'refused');
  assert.equal(grantFromProbe(403, APP_403)[0], 'refused-other');
  assert.equal(grantFromProbe(401, 'Bad credentials')[0], 'unauthenticated');
  const [verdict, why] = grantFromProbe(404, 'Not Found');
  assert.equal(verdict, 'ambiguous');
  assert.match(why, /404-masking-403/);
  assert.equal(grantFromProbe(null, '')[0], 'error');
});

test('a 404 in the matrix is never reported as a refusal', () => {
  const [state] = classify(404, 'Not Found', {}, FG);
  assert.equal(state, 'ambiguous-404');
  assert.match(repair(state), /404-masking-403/);
});

test('the missing permission is the named one the probes refused', () => {
  const grants = { metadata: 'granted', issues: 'refused' };
  assert.deepEqual(missingPermissions(REFUSED, grants), [['issues', 'read']]);
  assert.deepEqual(missingPermissions(REFUSED, { issues: 'granted' }), []);
  assert.deepEqual(missingPermissions({}, grants), []);
});

test('the same refusal through graphql carries no header', () => {
  const body = {
    data: { repository: null },
    errors: [
      { type: 'FORBIDDEN', path: ['repository', 'issues'], message: PAT_403 },
      { type: 'NOT_FOUND', message: 'Could not resolve' },
    ],
  };
  assert.deepEqual(graphqlPatRefusals(body), [['repository.issues', PAT_403]]);
  assert.deepEqual(graphqlPatRefusals({ data: {} }), []);
  assert.match(whereTheRequirementLives('graphql'),
    /no x-accepted-github-permissions header/);
  assert.match(whereTheRequirementLives('rest'),
    /x-accepted-github-permissions header/);
});

test('the document this script sends is a read', () => {
  assert.deepEqual(
    operations('query Q { repository(owner: "a", name: "b") { issues(first: 1) { totalCount } } }'),
    ['query'],
  );
  assert.ok(refusal('mutation M { addStar(input: {}) { clientMutationId } }'));
  assert.ok(refusal('subscription S { thing { id } }'));
  assert.equal(refusal(''), 'the document contains no operation to send.');
});

FAQ

How do I tell a fine-grained token from a classic one at runtime?

Two signals that agree. The prefix is the obvious one: github_pat_ for fine-grained, ghp_ for classic, gho_ for an OAuth user token, ghs_ for a GitHub App installation token. The response-level signal is stronger, because it survives a token being passed through something that trims it: a classic or OAuth token gets x-oauth-scopes on every authenticated response, even as an empty string when it holds no scopes, and a fine-grained token never gets that header at all. An empty header and an absent header therefore mean opposite things, and code that checks truthiness rather than presence gets this backwards.

Why does GitHub not just say which permission is missing?

It does say what the endpoint accepts — that is x-accepted-github-permissions, and it arrives on the refusing response — but it deliberately never reports what a token holds. Publishing a credential's full grant set on every response would hand an attacker who captured one response a complete map of what the stolen token can reach. The consequence for you is that the diff has one readable side, so the other side has to be measured with cheap reads against a repository you know exists.

Is this the same as the error a GitHub App gets?

Same shape, different actor, different repair. An App gets &ldquo;Resource not accessible by integration&rdquo; and its permissions are readable through GET /app under the App's JWT, so that diff can be done from two responses without probing. Adding a permission to an App also requires every existing installation to accept the upgrade, which has no counterpart here: editing a fine-grained token's permissions takes effect immediately for you, and only needs approval when the token reaches organization-owned resources. Read the word in the message before deciding which page to open.

The token has every permission I ticked and organization resources still 403.

Then the likely answer is not a permission at all. Organizations can require an owner to approve any fine-grained token that touches their resources, and can also refuse fine-grained tokens by policy. Until that approval lands, the token holds its permissions on paper and none of them in effect, so the shape you see is every personal and repository read succeeding while every organization read fails. Ticking more boxes changes nothing; the script separates that case so you go and find an owner rather than re-minting the token twice.

Does the refusal look different through GraphQL?

Completely, and that is worth knowing before you debug a GraphQL-only integration. There is no 403 to catch, because the response is a 200 with the failure written into the errors array, and there is no x-accepted-github-permissions header anywhere on the response, so the one authoritative statement of what was required is simply absent. What you get is a message naming the personal access token and a path pointing at the field that was refused. The technique is to take that path, find the REST endpoint that returns the same object, call it, and read the header off its refusal.

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.