Skip to content

Diagnostic GitHub API

A fine-grained token that is waiting for an org owner

The token was created this morning. The permissions were ticked carefully against the documentation, the resource owner was set to the organization, and the settings page shows it exists with everything it needs. GET /user returns 200. Reads against your own repositories return 200. Every single call that touches the organization returns 403, or 404, and the permission the endpoint names is one the token visibly holds. Nothing is missing. The token is sitting in a queue waiting for an organization owner to approve it, and the API has no interest in telling you that.

Read-only token Python and Node.js Tests included
A laptop on a desk
Photo by Markus Winkler on Unsplash
The short answer

An organization can require owner approval for any fine-grained personal access token that targets its resources. Until somebody approves the request, the token holds its permissions on paper and none of them in practice — it authenticates perfectly and is refused on everything the organization owns.

The tell is the shape of the failures, not any header. A missing permission fails one endpoint family everywhere: issues are refused on your own repositories too. A pending approval fails every endpoint family in one namespace: repositories, issues and members all refused under the organization while personal reads succeed. One is endpoint-shaped, the other is owner-shaped, and a handful of cheap GETs tells them apart.

Where an owner's credential is available, GET /orgs/{org}/personal-access-token-requests lists the waiting requests and settles it outright. The repair is a person approving one; there is nothing to re-request, because the request already exists.

The problem in plain words

The reason this eats a day is that every instinct is to fix the token, and the token is already correct. The 403 mentions a permission, the permission is right there on the settings page with a tick beside it, so the natural conclusion is that the tick did not take. Somebody edits the token. Nothing changes. Somebody deletes it and makes a new one, which feels like progress and is actually the worst available move: creating a token against an organization that requires approval simply files a second request behind the first, so the queue got longer and the wait started over.

Meanwhile the evidence looks contradictory in a way that invites theories. The credential authenticates, which rules out most things. It reads personal repositories, which rules out most of the rest. It fails on organization resources with a message about permissions it demonstrably has. People start suspecting propagation delay, then caching, then the organization's SAML setup, then a GitHub incident. All of those are reasonable guesses about a system that is behaving exactly as designed and simply has not told anyone.

The last twist is who is waiting on whom. The approval request landed in an organization owner's settings page, which for a large organization is a list nobody reads daily and for a small one is a page whose existence is not widely known. Nobody was notified in a way that felt urgent. So the engineer waits for a system to finish something, the owner has no idea anybody is blocked, and the fix is a click that takes four seconds once somebody says the word “approve” out loud.

Token createdpermissions tickedOrg calls 403personal callsfineHeader names apermissionthe token alreadyholds itToken deletedand remadequeue gets longerOwner nevertoldthe request waitsand a third token is minted
The move in the middle is the expensive one. A replacement token files a second request behind the first.

Why it happens

Permissions and admission are two separate questions. A fine-grained token's permissions describe what it may do if it is allowed near a resource owner at all. The organization's token policy decides whether it is allowed near. That is why the settings page and the refusal can both be telling the truth simultaneously, and why nothing you change on the token moves the answer: the token was never the disputed thing.

The refusal's header cannot settle it, and knowing why saves the afternoon. x-accepted-github-permissions names what the endpoint accepts. It is a property of the endpoint, so it can turn up on a refusal whatever the underlying cause, and it never names what the token holds. That asymmetry has its own note; here the consequence is narrower and more useful — the header is not the discriminator, so do not spend an hour reading it as one.

The discriminator is the shape, and the shape is measurable. A missing permission is endpoint-shaped: whatever the token cannot do, it cannot do anywhere, including on repositories it owns outright. A pending approval is owner-shaped: everything fails under one resource owner and nothing fails under another. Probing two namespaces across three endpoint families settles which, in six requests, without a single write.

No x-github-sso and no OAuth restriction message. Both of the neighbouring organization gates announce themselves. SAML enforcement puts a header on the refusal; an OAuth App restriction says so in the message and applies only to OAuth Apps, which a fine-grained token is not. Their absence beside an owner-shaped failure is what leaves pending approval standing, and the script records those absences as evidence rather than skipping past them.

There is an authoritative reading, and it belongs to somebody else. GET /orgs/{org}/personal-access-token-requests lists the pending requests with the requester, the permissions asked for and the date. It needs admin:org, so the person who is blocked usually cannot run it — which is fitting, because the person who can run it is exactly the person who can end the wait. The script reads it with a second credential when one is offered and corroborates the behavioural finding with a date.

Detect the pending state; never trigger it. Approving is a write and this script does not write, but the sharper rule is that it never asks for approval either. The request already exists — it was filed when the token was created — so there is nothing to resubmit, and resubmitting would only add a duplicate to somebody's queue. The output names the settings page where an owner approves the waiting request, and stops.

The fix, as a flow

Six cheap reads and one question: do the refusals cluster around an owner or around an endpoint family. A permission the token lacks fails the same family everywhere, including on repositories the account owns outright. A token waiting in an approval queue fails every family under one owner and none of them at home. That is the whole diagnosis, and it is arithmetic over six numbers, which is why the script refuses to answer on one organization family alone. The permissions header is read and then explicitly set aside: it describes the endpoint and never the token, so it agrees with the settings page and settles nothing. Where an owner's credential is offered, the pending request adds a date to the finding.

Six reads, two namespacesthree endpoint families eachEvery org family refusedowner-shaped: waiting for approvalOne family refused at home tooendpoint-shaped: a permissionOnly one family readnot enough to name a causeOrg families answeringthe owner has admitted this token
These rows are patterns, not values. No single response carries the one thing that separates the top two.

How to fix it

Confirm the credential type, because this policy is fine-grained only

Read locally from the prefix. Organization approval policy applies to fine-grained personal access tokens; a classic token reaching the same organization is governed by SAML authorization instead, and an App installation token by the installation. Getting this wrong sends you to a settings page that has no row for your credential.

Probe two namespaces across three endpoint families

Six cheap reads: the authenticated user, personal repositories and personal issues, then the organization's repositories, issues and members. All of them per_page=1. The point is not any single status but the pattern across them, which is why the script insists on more than one family before it will say anything at all.

Decide whether the failure is owner-shaped or endpoint-shaped

Every organization family refused while personal families answer is owner-shaped: the gate is the resource owner, and pending approval is what that looks like. One family refused in both namespaces is endpoint-shaped: the token is short a permission and the note that owns that is a click away. Anything else is reported as unclassified rather than forced into a verdict.

Record the absences that rule out the neighbouring gates

The script notes whether x-github-sso was on any refusal and whether the message matched an OAuth App restriction. Both absent, beside an owner-shaped failure, is what leaves this diagnosis standing. It also states in the output that x-accepted-github-permissions is not a discriminator here, because that header is where people spend the hour they did not need to spend.

Corroborate with an owner's credential, and print the approval step

Given GITHUB_ADMIN_TOKEN, the script reads the organization's pending token requests, finds the one filed by this account, and reports how many days it has been waiting. Then it prints where an owner approves it. It does not approve, does not request, and does not re-request: the request already exists, and filing another only puts a duplicate in the queue.

How to check it worked

After an owner approves the waiting request, the same six probes come back and only the shape has changed.

GITHUB_TOKEN=$FINE_GRAINED_TOKEN GITHUB_ADMIN_TOKEN=$OWNER_TOKEN \
    python3 github_pat_pending_approval.py acme-corp
# read cost: 7 request(s) against the core hourly quota including one read
#   with the owner's credential
# credential: fine-grained PAT, account=dana
# personal  user=200  repositories=200  issues=200
# org       repositories=403  issues=403  members=403
# shape: owner-shaped - every organization family is refused and no personal
#   family is, so the gate is the resource owner and not any endpoint
# x-github-sso: absent on every refusal
# oauth restriction wording: not present
# note: x-accepted-github-permissions describes what the endpoint accepts and
#   never what the token holds, so it cannot settle this either way
# pending-org-approval: this token is waiting for an organization owner to
#   approve it. Its permissions are held on paper and none in practice.
# pending request: filed 6 day(s) ago by dana, repository_selection=all
# repair: an owner of acme-corp approves the waiting request under the
#   organization's personal access tokens settings. This script does not
#   approve it and does not ask for it. Do not create a replacement token:
#   the request already exists and a new one only queues behind it.

The full code

The interesting function takes no arguments from the network. probe_shape receives two lists of endpoint family and status and answers one question — is this failure shaped like an owner or shaped like an endpoint — which is the whole diagnosis and is pure arithmetic over six numbers. Everything around it is the discipline that makes the arithmetic trustworthy: refusing to answer on a single family, recording two absent signals as evidence rather than as nothing, saying out loud that the permissions header cannot settle this, and treating the owner's view of the pending queue as corroboration rather than as the primary reading, because the person who is blocked usually cannot take it.

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_pat_pending_approval.py
"""Tell a token waiting for an owner apart from a token short a permission.

Read only. GET requests and nothing else, and a second promise this note needs
more than most: it never approves anything and never asks for approval. The
request this script detects already exists -- it was filed the moment the token
was created -- so there is nothing to resubmit, and resubmitting would only put
a duplicate into somebody's queue.

The diagnosis is a shape. A missing permission is endpoint-shaped: whatever the
token cannot do, it cannot do anywhere, including on repositories the account
owns outright. A pending organization approval is owner-shaped: every endpoint
family fails under one resource owner while personal reads succeed. Six cheap
reads separate them.

What this can and cannot see: it can prove the shape, and it can record that
neither of the neighbouring gates announced itself. It cannot read the
organization's token policy, and it cannot read the pending request with the
credential that is blocked by it -- that needs admin:org, which is why the
authoritative reading is optional here and belongs to the person who can also
end the wait.

Environment:

    GITHUB_TOKEN        the fine-grained token being refused
    GITHUB_ADMIN_TOKEN  an organization owner's credential, admin:org (optional)
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_pat_pending_approval")

API = "https://api.github.com"
UA = "github-pat-pending-approval/1.0"

SSO_HEADER = "x-github-sso"
ACCEPTED_PERMISSIONS_HEADER = "x-accepted-github-permissions"

TOKEN_PREFIXES = (
    ("github_pat_", "fine-grained PAT"),
    ("ghp_", "classic PAT"),
    ("gho_", "OAuth user token"),
    ("ghu_", "App user-to-server token"),
    ("ghs_", "App installation token"),
    ("ghr_", "App refresh token"),
)

# Two namespaces, several endpoint families each. The families matter more than
# the endpoints: the question is whether refusals cluster by owner or by
# family, and one endpoint cannot answer that.
PERSONAL_PROBES = (
    ("user", "/user"),
    ("repositories", "/user/repos?per_page=1"),
    ("issues", "/issues?per_page=1"),
)
ORG_PROBES = (
    ("repositories", "/orgs/%s/repos?per_page=1"),
    ("issues", "/orgs/%s/issues?per_page=1"),
    ("members", "/orgs/%s/members?per_page=1"),
)

REFUSED = (403, 404)

# Corroboration for ruling the neighbouring gate out, never for ruling this one
# in. The wording belongs to GitHub and can be edited at any time.
OAUTH_RESTRICTION_PHRASES = (
    "oauth app access restrictions",
    "third-party application",
)


def read_cost(with_admin=False):
    """Requests this run will spend against the core quota. Pure."""
    return len(PERSONAL_PROBES) + len(ORG_PROBES) + (1 if with_admin else 0)


def token_kind(token):
    """Name the credential from its prefix. Pure; nothing leaves the machine."""
    value = (token or "").strip()
    for prefix, name in TOKEN_PREFIXES:
        if value.startswith(prefix):
            return name
    return "unknown"


def probe_shape(personal, org):
    """Is this failure shaped like an owner or like an endpoint. Pure.

    personal, org: [(family, status), ...]. Returns (shape, detail).

    The refusal to answer on thin evidence is deliberate. One organization
    family failing tells you nothing about whether the gate is the owner, and a
    script that guesses there sends people to the wrong settings page.
    """
    if len(org) < 2:
        return ("insufficient-evidence",
                "fewer than two organization endpoint families were read, and "
                "one family cannot show whether refusals cluster by owner.")
    org_refused = [f for f, s in org if s in REFUSED]
    org_ok = [f for f, s in org if s == 200]
    personal_ok = [f for f, s in personal if s == 200]
    personal_refused = [f for f, s in personal if s in REFUSED]

    if not personal_ok:
        return ("credential-shaped",
                "nothing succeeded in the personal namespace either, so the "
                "credential itself is the thing to look at first.")
    if len(org_refused) == len(org) and not personal_refused:
        return ("owner-shaped",
                "every organization family is refused and no personal family "
                "is, so the gate is the resource owner and not any endpoint.")
    if org_ok and org_refused:
        shared = sorted(set(org_refused) & set(f for f, s in personal
                                               if s in REFUSED))
        if shared:
            return ("endpoint-shaped",
                    "the same family is refused in both namespaces (%s), which "
                    "is a permission the token does not hold rather than an "
                    "owner refusing it." % ", ".join(shared))
        return ("endpoint-shaped",
                "some organization families answer and others do not, so the "
                "owner is admitting this token and individual permissions are "
                "what is short.")
    if not org_refused:
        return ("nothing-refused",
                "every family answered in both namespaces, so nothing is "
                "waiting on anybody today.")
    return ("unclassified-shape",
            "the pattern does not match owner-shaped or endpoint-shaped; "
            "report the statuses rather than naming a cause.")


def header_is_not_the_discriminator():
    """The sentence that saves an hour. Pure."""
    return ("x-accepted-github-permissions describes what the endpoint accepts "
            "and never what the token holds, so it cannot settle this either "
            "way.")


def oauth_wording(message):
    """Did the refusal blame an OAuth App restriction. Pure."""
    text = str(message or "").lower()
    return any(p in text for p in OAUTH_RESTRICTION_PHRASES)


def classify(shape, kind, sso_seen, oauth_seen):
    """The verdict. Pure. (state, detail).

    The neighbouring gates are checked before the shape, because each of them
    announces itself and a diagnosis established by exclusion must never
    outrank one that was stated outright.
    """
    if kind != "fine-grained PAT":
        return ("not-a-fine-grained-token",
                "organization approval policy applies to fine-grained personal "
                "access tokens. A %s reaching this organization is governed by "
                "something else, with a different repair." % kind)
    if sso_seen:
        return ("saml-enforcement",
                "a refusal carried x-github-sso, so SAML enforcement is in play "
                "and that is a different note.")
    if oauth_seen:
        return ("oauth-app-restriction",
                "the refusal blamed OAuth App access restrictions, which govern "
                "applications rather than personal access tokens.")
    if shape == "owner-shaped":
        return ("pending-org-approval",
                "this token is waiting for an organization owner to approve it. "
                "Its permissions are held on paper and none in practice, which "
                "is why editing them changes nothing.")
    if shape == "endpoint-shaped":
        return ("permission-shaped",
                "the refusals follow an endpoint family rather than an owner, "
                "so this is a permission the token does not hold.")
    if shape == "credential-shaped":
        return ("credential-problem",
                "personal reads are failing too, so start with the credential.")
    if shape == "nothing-refused":
        return ("not-blocked", "nothing was refused during this run.")
    return ("undetermined",
            "not enough evidence to name a cause. Read more families before "
            "acting on this.")


def days_pending(created_at, now):
    """Whole days a request has been waiting. Pure."""
    if not created_at:
        return None
    text = str(created_at).strip().replace("Z", "+00:00")
    try:
        when = datetime.fromisoformat(text)
    except ValueError:
        return None
    if not when.tzinfo:
        when = when.replace(tzinfo=timezone.utc)
    return int((now - when).total_seconds() // 86400)


def find_request(requests_list, login):
    """The pending request filed by this account, if any. Pure.

    Matching is on the requester's login, which is public information and safe
    to print, unlike anything derived from the credential itself.
    """
    for item in requests_list or []:
        if not isinstance(item, dict):
            continue
        owner = item.get("owner") or {}
        if str(owner.get("login") or "").lower() == str(login or "").lower():
            return item
    return None


def repair(state, org):
    """The sentence a reader has to act on. Pure."""
    if state == "pending-org-approval":
        return ("an owner of %s approves the waiting request under the "
                "organization's personal access tokens settings. This script "
                "does not approve it and does not ask for it. Do not create a "
                "replacement token: the request already exists and a new one "
                "only queues behind it." % org)
    if state == "permission-shaped":
        return ("read x-accepted-github-permissions off the refusal, tick that "
                "permission on the token, and expect the organization to "
                "re-approve the change if it requires approval.")
    if state == "saml-enforcement":
        return "follow the SSO authorization URL on the refusal instead."
    if state == "oauth-app-restriction":
        return ("have an owner approve the application; this is a policy about "
                "an app rather than about a token.")
    if state == "not-a-fine-grained-token":
        return ("find the gate that applies to this credential type before "
                "looking at any approval queue.")
    if state == "credential-problem":
        return "fix the credential; no organization queue is involved yet."
    if state == "not-blocked":
        return "nothing. This token is reaching %s right now." % org
    return "read more endpoint families and run this again."


def get(session, url):
    """One GET. Returns the response object."""
    r = session.get(url, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
                         "or revoked. That is a different note.")
    return r


def session_for(token):
    s = requests.Session()
    s.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        # GitHub rejects requests with no User-Agent outright.
        "User-Agent": UA,
    })
    return s


def body_message(response):
    """The API's message string, if the body has one."""
    try:
        payload = response.json()
    except ValueError:
        return ""
    return (payload or {}).get("message", "") if isinstance(payload, dict) else ""


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("org", help="the organization whose resources are refused")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (the fine-grained token being refused)")
        return 2
    admin = os.environ.get("GITHUB_ADMIN_TOKEN")

    log.info("read cost: %d request(s) against the core hourly quota%s",
             read_cost(bool(admin)),
             " including one read with the owner's credential" if admin else "")

    kind = token_kind(token)
    session = session_for(token)

    personal = []
    account = None
    for family, path in PERSONAL_PROBES:
        response = get(session, API + path)
        personal.append((family, response.status_code))
        if family == "user" and response.status_code == 200:
            account = (response.json() or {}).get("login")
    log.info("credential: %s, account=%s", kind, account or "unreadable")
    log.info("personal  %s", "  ".join("%s=%s" % (f, s) for f, s in personal))

    org_results = []
    sso_seen = False
    oauth_seen = False
    accepted_seen = None
    for family, template in ORG_PROBES:
        response = get(session, API + (template % args.org))
        org_results.append((family, response.status_code))
        if response.status_code in REFUSED:
            if response.headers.get(SSO_HEADER):
                sso_seen = True
            if oauth_wording(body_message(response)):
                oauth_seen = True
            accepted_seen = (response.headers.get(ACCEPTED_PERMISSIONS_HEADER)
                             or accepted_seen)
    log.info("org       %s", "  ".join("%s=%s" % (f, s) for f, s in org_results))

    shape, shape_detail = probe_shape(personal, org_results)
    log.info("shape: %s - %s", shape, shape_detail)
    log.info("%s: %s", SSO_HEADER,
             "present on a refusal" if sso_seen else "absent on every refusal")
    log.info("oauth restriction wording: %s",
             "present" if oauth_seen else "not present")
    log.info("note: %s", header_is_not_the_discriminator())

    state, detail = classify(shape, kind, sso_seen, oauth_seen)
    log.info("%s: %s", state, detail)

    pending = None
    waiting_days = None
    if admin:
        owner_session = session_for(admin)
        listing = get(owner_session,
                      API + "/orgs/%s/personal-access-token-requests?per_page=100"
                      % args.org)
        if listing.status_code == 200:
            body = listing.json()
            pending = find_request(body if isinstance(body, list) else [], account)
            if pending:
                waiting_days = days_pending(pending.get("created_at"),
                                            datetime.now(timezone.utc))
                log.info("pending request: filed %s day(s) ago by %s, "
                         "repository_selection=%s",
                         waiting_days, account,
                         pending.get("repository_selection"))
            else:
                log.info("pending request: none filed by %s is waiting, which "
                         "argues against this verdict", account)
        else:
            log.warning("personal-access-token-requests returned HTTP %s; that "
                        "endpoint needs admin:org", listing.status_code)

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

    print(json.dumps({
        "organization": args.org,
        "account": account,
        "credential_kind": kind,
        "personal": dict(personal),
        "org": dict(org_results),
        "shape": shape,
        "shape_detail": shape_detail,
        "sso_header_seen": sso_seen,
        "oauth_wording_seen": oauth_seen,
        "accepted_permissions_header": accepted_seen,
        "accepted_permissions_note": header_is_not_the_discriminator(),
        "pending_request_found": bool(pending),
        "pending_request_days": waiting_days,
        "state": state,
        "detail": detail,
        "repair": repair(state, args.org),
    }, indent=2, default=str))
    return 1 if state == "pending-org-approval" else 0


if __name__ == "__main__":
    sys.exit(main())
github-pat-pending-approval.mjs
/**
 * Tell a token waiting for an owner apart from a token short a permission.
 *
 * Read only, and it never approves anything or asks for approval. The request
 * this script detects already exists: it was filed the moment the token was
 * created, so there is nothing to resubmit and resubmitting would only put a
 * duplicate into somebody queue.
 *
 * A missing permission is endpoint-shaped: whatever the token cannot do, it
 * cannot do anywhere. A pending organization approval is owner-shaped: every
 * endpoint family fails under one resource owner while personal reads succeed.
 *
 * Environment:
 *   GITHUB_TOKEN        the fine-grained token being refused
 *   GITHUB_ADMIN_TOKEN  an organization owner credential with admin:org
 *   GITHUB_ORG          the organization whose resources are refused
 */
const API = 'https://api.github.com';
const UA = 'github-pat-pending-approval/1.0';

export const SSO_HEADER = 'x-github-sso';
export const ACCEPTED_PERMISSIONS_HEADER = 'x-accepted-github-permissions';

export const TOKEN_PREFIXES = [
  ['github_pat_', 'fine-grained PAT'],
  ['ghp_', 'classic PAT'],
  ['gho_', 'OAuth user token'],
  ['ghu_', 'App user-to-server token'],
  ['ghs_', 'App installation token'],
  ['ghr_', 'App refresh token'],
];

export const PERSONAL_PROBES = [
  ['user', '/user'],
  ['repositories', '/user/repos?per_page=1'],
  ['issues', '/issues?per_page=1'],
];
export const ORG_PROBES = [
  ['repositories', (org) => `/orgs/${org}/repos?per_page=1`],
  ['issues', (org) => `/orgs/${org}/issues?per_page=1`],
  ['members', (org) => `/orgs/${org}/members?per_page=1`],
];

export const REFUSED = [403, 404];

export const OAUTH_RESTRICTION_PHRASES = [
  'oauth app access restrictions',
  'third-party application',
];

/** Requests this run will spend against the core quota. Pure. */
export function readCost(withAdmin = false) {
  return PERSONAL_PROBES.length + ORG_PROBES.length + (withAdmin ? 1 : 0);
}

/** Name the credential from its prefix. Pure. */
export function tokenKind(token) {
  const value = String(token ?? '').trim();
  for (const [prefix, name] of TOKEN_PREFIXES) {
    if (value.startsWith(prefix)) return name;
  }
  return 'unknown';
}

/** Is this failure shaped like an owner or like an endpoint. Pure. */
export function probeShape(personal, org) {
  if (org.length < 2) {
    return ['insufficient-evidence', 'fewer than two organization endpoint '
      + 'families were read, and one family cannot show whether refusals '
      + 'cluster by owner.'];
  }
  const refused = (s) => REFUSED.includes(s);
  const orgRefused = org.filter(([, s]) => refused(s)).map(([f]) => f);
  const orgOk = org.filter(([, s]) => s === 200).map(([f]) => f);
  const personalOk = personal.filter(([, s]) => s === 200).map(([f]) => f);
  const personalRefused = personal.filter(([, s]) => refused(s)).map(([f]) => f);

  if (personalOk.length === 0) {
    return ['credential-shaped', 'nothing succeeded in the personal namespace '
      + 'either, so the credential itself is the thing to look at first.'];
  }
  if (orgRefused.length === org.length && personalRefused.length === 0) {
    return ['owner-shaped', 'every organization family is refused and no '
      + 'personal family is, so the gate is the resource owner and not any endpoint.'];
  }
  if (orgOk.length > 0 && orgRefused.length > 0) {
    const shared = orgRefused.filter((f) => personalRefused.includes(f)).sort();
    if (shared.length > 0) {
      return ['endpoint-shaped', `the same family is refused in both namespaces `
        + `(${shared.join(', ')}), which is a permission the token does not hold `
        + 'rather than an owner refusing it.'];
    }
    return ['endpoint-shaped', 'some organization families answer and others do '
      + 'not, so the owner is admitting this token and individual permissions '
      + 'are what is short.'];
  }
  if (orgRefused.length === 0) {
    return ['nothing-refused', 'every family answered in both namespaces, so '
      + 'nothing is waiting on anybody today.'];
  }
  return ['unclassified-shape', 'the pattern does not match owner-shaped or '
    + 'endpoint-shaped; report the statuses rather than naming a cause.'];
}

/** The sentence that saves an hour. Pure. */
export function headerIsNotTheDiscriminator() {
  return 'x-accepted-github-permissions describes what the endpoint accepts and '
    + 'never what the token holds, so it cannot settle this either way.';
}

/** Did the refusal blame an OAuth App restriction. Pure. */
export function oauthWording(message) {
  const text = String(message ?? '').toLowerCase();
  return OAUTH_RESTRICTION_PHRASES.some((p) => text.includes(p));
}

/** The verdict. Pure. [state, detail]. */
export function classify(shape, kind, ssoSeen, oauthSeen) {
  if (kind !== 'fine-grained PAT') {
    return ['not-a-fine-grained-token', 'organization approval policy applies to '
      + `fine-grained personal access tokens. A ${kind} is governed by something `
      + 'else, with a different repair.'];
  }
  if (ssoSeen) {
    return ['saml-enforcement', 'a refusal carried x-github-sso, so SAML '
      + 'enforcement is in play and that is a different note.'];
  }
  if (oauthSeen) {
    return ['oauth-app-restriction', 'the refusal blamed OAuth App access '
      + 'restrictions, which govern applications rather than personal tokens.'];
  }
  if (shape === 'owner-shaped') {
    return ['pending-org-approval', 'this token is waiting for an organization '
      + 'owner to approve it. Its permissions are held on paper and none in '
      + 'practice, which is why editing them changes nothing.'];
  }
  if (shape === 'endpoint-shaped') {
    return ['permission-shaped', 'the refusals follow an endpoint family rather '
      + 'than an owner, so this is a permission the token does not hold.'];
  }
  if (shape === 'credential-shaped') {
    return ['credential-problem', 'personal reads are failing too, so start with '
      + 'the credential.'];
  }
  if (shape === 'nothing-refused') {
    return ['not-blocked', 'nothing was refused during this run.'];
  }
  return ['undetermined', 'not enough evidence to name a cause.'];
}

/** Whole days a request has been waiting. Pure. */
export function daysPending(createdAt, nowMs) {
  if (!createdAt) return null;
  const when = Date.parse(String(createdAt));
  if (Number.isNaN(when)) return null;
  return Math.floor((nowMs - when) / 86400000);
}

/** The pending request filed by this account, if any. Pure. */
export function findRequest(requestsList, login) {
  for (const item of requestsList || []) {
    if (!item || typeof item !== 'object') continue;
    const owner = item.owner || {};
    if (String(owner.login ?? '').toLowerCase() === String(login ?? '').toLowerCase()) {
      return item;
    }
  }
  return null;
}

/** The sentence a reader has to act on. Pure. */
export function repair(state, org) {
  if (state === 'pending-org-approval') {
    return `an owner of ${org} approves the waiting request under the `
      + 'organization personal access tokens settings. This script does not '
      + 'approve it and does not ask for it. Do not create a replacement token: '
      + 'the request already exists and a new one only queues behind it.';
  }
  if (state === 'permission-shaped') {
    return 'read x-accepted-github-permissions off the refusal, tick that '
      + 'permission on the token, and expect the organization to re-approve it.';
  }
  if (state === 'saml-enforcement') {
    return 'follow the SSO authorization URL on the refusal instead.';
  }
  if (state === 'oauth-app-restriction') {
    return 'have an owner approve the application; this is a policy about an app.';
  }
  if (state === 'not-a-fine-grained-token') {
    return 'find the gate that applies to this credential type first.';
  }
  if (state === 'credential-problem') {
    return 'fix the credential; no organization queue is involved yet.';
  }
  if (state === 'not-blocked') {
    return `nothing. This token is reaching ${org} right now.`;
  }
  return 'read more endpoint families and run this again.';
}

function headers(token) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const org = process.env.GITHUB_ORG;
  if (!token || !org) {
    console.error('set GITHUB_TOKEN (the fine-grained token) and GITHUB_ORG');
    process.exitCode = 2;
    return;
  }
  const admin = process.env.GITHUB_ADMIN_TOKEN;
  console.log(`read cost: ${readCost(Boolean(admin))} request(s) against the core hourly quota`);

  const kind = tokenKind(token);
  const personal = [];
  let account = null;
  for (const [family, path] of PERSONAL_PROBES) {
    const response = await fetch(API + path, { headers: headers(token) });
    personal.push([family, response.status]);
    if (family === 'user' && response.status === 200) {
      account = (await response.json()).login;
    }
  }
  console.log(`credential: ${kind}, account=${account ?? 'unreadable'}`);
  console.log(`personal  ${personal.map(([f, s]) => `${f}=${s}`).join('  ')}`);

  const orgResults = [];
  let ssoSeen = false;
  let oauthSeen = false;
  let acceptedSeen = null;
  for (const [family, template] of ORG_PROBES) {
    const response = await fetch(API + template(org), { headers: headers(token) });
    orgResults.push([family, response.status]);
    if (REFUSED.includes(response.status)) {
      if (response.headers.get(SSO_HEADER)) ssoSeen = true;
      acceptedSeen = response.headers.get(ACCEPTED_PERMISSIONS_HEADER) || acceptedSeen;
      try {
        const body = await response.json();
        if (body && typeof body === 'object' && oauthWording(body.message)) oauthSeen = true;
      } catch { /* an empty body is not evidence either way */ }
    }
  }
  console.log(`org       ${orgResults.map(([f, s]) => `${f}=${s}`).join('  ')}`);

  const [shape, shapeDetail] = probeShape(personal, orgResults);
  console.log(`shape: ${shape} - ${shapeDetail}`);
  console.log(`${SSO_HEADER}: ${ssoSeen ? 'present on a refusal' : 'absent on every refusal'}`);
  console.log(`note: ${headerIsNotTheDiscriminator()}`);

  const [state, detail] = classify(shape, kind, ssoSeen, oauthSeen);
  console.log(`${state}: ${detail}`);

  let pending = null;
  let waitingDays = null;
  if (admin) {
    const listing = await fetch(
      `${API}/orgs/${org}/personal-access-token-requests?per_page=100`,
      { headers: headers(admin) },
    );
    if (listing.status === 200) {
      const body = await listing.json();
      pending = findRequest(Array.isArray(body) ? body : [], account);
      if (pending) {
        waitingDays = daysPending(pending.created_at, Date.now());
        console.log(`pending request: filed ${waitingDays} day(s) ago by ${account}`);
      } else {
        console.log(`pending request: none filed by ${account} is waiting`);
      }
    } else {
      console.warn(`personal-access-token-requests returned HTTP ${listing.status}; `
        + 'that endpoint needs admin:org');
    }
  }

  console.log(`repair: ${repair(state, org)}`);
  console.log(JSON.stringify({
    organization: org,
    account,
    credential_kind: kind,
    personal: Object.fromEntries(personal),
    org: Object.fromEntries(orgResults),
    shape,
    shape_detail: shapeDetail,
    sso_header_seen: ssoSeen,
    oauth_wording_seen: oauthSeen,
    accepted_permissions_header: acceptedSeen,
    accepted_permissions_note: headerIsNotTheDiscriminator(),
    pending_request_found: Boolean(pending),
    pending_request_days: waitingDays,
    state,
    detail,
    repair: repair(state, org),
  }, null, 2));
  process.exitCode = state === 'pending-org-approval' ? 1 : 0;
}

// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The two shapes are asserted side by side, because the note is the difference between them and a reader should be able to see it in the fixtures: the same three families, refused under one owner in the first and refused under one family in the second. Then the refusals to answer — one organization family is not enough evidence, and a personal namespace that is also failing is a credential problem rather than a queue. The last group holds the line on the repair: an approval that is printed and never requested, and an explicit instruction not to mint a replacement token, since that is the move everybody makes and it lengthens the queue it was meant to jump.

test_github_pat_pending_approval.py
from datetime import datetime, timezone

from github_pat_pending_approval import (
    classify, days_pending, find_request, header_is_not_the_discriminator,
    oauth_wording, probe_shape, read_cost, repair, token_kind,
)

NOW = datetime(2026, 8, 31, 12, 0, 0, tzinfo=timezone.utc)

PERSONAL_OK = [("user", 200), ("repositories", 200), ("issues", 200)]
ORG_ALL_REFUSED = [("repositories", 403), ("issues", 403), ("members", 403)]
ORG_ONE_FAMILY = [("repositories", 200), ("issues", 403), ("members", 200)]


def test_an_owner_shaped_failure_refuses_every_family_in_one_namespace():
    shape, detail = probe_shape(PERSONAL_OK, ORG_ALL_REFUSED)
    assert shape == "owner-shaped"
    assert "the gate is the resource owner" in detail
    state, why = classify(shape, "fine-grained PAT", False, False)
    assert state == "pending-org-approval"
    assert "on paper and none in practice" in why


def test_an_endpoint_shaped_failure_follows_one_family_everywhere():
    # The same token, the same organization, and a completely different cause:
    # issues are refused in both namespaces, so the permission is what is short.
    personal = [("user", 200), ("repositories", 200), ("issues", 403)]
    shape, detail = probe_shape(personal, ORG_ONE_FAMILY)
    assert shape == "endpoint-shaped"
    assert "issues" in detail
    assert classify(shape, "fine-grained PAT", False, False)[0] == "permission-shaped"


def test_one_organization_family_is_not_enough_to_name_a_cause():
    shape, detail = probe_shape(PERSONAL_OK, [("repositories", 403)])
    assert shape == "insufficient-evidence"
    assert "one family cannot show" in detail
    assert classify(shape, "fine-grained PAT", False, False)[0] == "undetermined"


def test_a_failing_personal_namespace_is_a_credential_not_a_queue():
    personal = [("user", 200), ("repositories", 403), ("issues", 403)]
    dead = [("user", 403), ("repositories", 403), ("issues", 403)]
    assert probe_shape(dead, ORG_ALL_REFUSED)[0] == "credential-shaped"
    # A partly-failing personal namespace is not owner-shaped either.
    assert probe_shape(personal, ORG_ALL_REFUSED)[0] != "owner-shaped"


def test_a_clean_run_says_nothing_is_waiting():
    ok = [("repositories", 200), ("issues", 200), ("members", 200)]
    assert probe_shape(PERSONAL_OK, ok)[0] == "nothing-refused"
    assert classify("nothing-refused", "fine-grained PAT", False, False)[0] == "not-blocked"


def test_the_neighbouring_gates_outrank_the_shape_because_they_announce_themselves():
    assert classify("owner-shaped", "fine-grained PAT", True, False)[0] == "saml-enforcement"
    assert classify("owner-shaped", "fine-grained PAT", False, True)[0] == "oauth-app-restriction"
    assert oauth_wording("the acme-corp organization has enabled OAuth App "
                         "access restrictions") is True
    assert oauth_wording("Resource not accessible by personal access token") is False


def test_a_classic_token_is_never_sent_to_the_approval_queue():
    state, detail = classify("owner-shaped", "classic PAT", False, False)
    assert state == "not-a-fine-grained-token"
    assert "different repair" in detail


def test_the_permissions_header_is_stated_not_to_be_the_discriminator():
    note = header_is_not_the_discriminator()
    assert "never what the token holds" in note
    assert "cannot settle this" in note


def test_the_repair_prints_the_approval_and_forbids_a_second_token():
    fix = repair("pending-org-approval", "acme-corp")
    assert "an owner of acme-corp approves the waiting request" in fix
    assert "does not approve it and does not ask for it" in fix
    assert "Do not create a replacement token" in fix


def test_the_pending_request_is_matched_on_a_public_login():
    pending = [{"id": 42, "owner": {"login": "Dana"},
                "repository_selection": "all",
                "created_at": "2026-08-25T09:00:00Z"}]
    found = find_request(pending, "dana")
    assert found["id"] == 42
    assert find_request(pending, "someone-else") is None
    assert days_pending(found["created_at"], NOW) == 6
    assert days_pending(None, NOW) is None
    assert days_pending("not a date", NOW) is None


def test_the_credential_type_comes_from_its_prefix():
    assert token_kind("github_pat_x") == "fine-grained PAT"
    assert token_kind("ghp_fake") == "classic PAT"
    assert token_kind("nope") == "unknown"


def test_the_run_costs_six_reads_or_seven():
    assert read_cost() == 6
    assert read_cost(True) == 7
github-pat-pending-approval.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  classify, daysPending, findRequest, headerIsNotTheDiscriminator, oauthWording,
  probeShape, readCost, repair, tokenKind,
} from './github-pat-pending-approval.mjs';

const NOW = Date.parse('2026-08-31T12:00:00Z');
const PERSONAL_OK = [['user', 200], ['repositories', 200], ['issues', 200]];
const ORG_ALL_REFUSED = [['repositories', 403], ['issues', 403], ['members', 403]];
const ORG_ONE_FAMILY = [['repositories', 200], ['issues', 403], ['members', 200]];

test('an owner shaped failure refuses every family in one namespace', () => {
  const [shape, detail] = probeShape(PERSONAL_OK, ORG_ALL_REFUSED);
  assert.equal(shape, 'owner-shaped');
  assert.ok(detail.includes('the gate is the resource owner'));
  assert.equal(classify(shape, 'fine-grained PAT', false, false)[0], 'pending-org-approval');
});

test('an endpoint shaped failure follows one family everywhere', () => {
  const personal = [['user', 200], ['repositories', 200], ['issues', 403]];
  const [shape, detail] = probeShape(personal, ORG_ONE_FAMILY);
  assert.equal(shape, 'endpoint-shaped');
  assert.ok(detail.includes('issues'));
  assert.equal(classify(shape, 'fine-grained PAT', false, false)[0], 'permission-shaped');
});

test('one organization family is not enough to name a cause', () => {
  assert.equal(probeShape(PERSONAL_OK, [['repositories', 403]])[0], 'insufficient-evidence');
  assert.equal(classify('insufficient-evidence', 'fine-grained PAT', false, false)[0],
    'undetermined');
});

test('a failing personal namespace is a credential not a queue', () => {
  const dead = [['user', 403], ['repositories', 403], ['issues', 403]];
  assert.equal(probeShape(dead, ORG_ALL_REFUSED)[0], 'credential-shaped');
});

test('the neighbouring gates outrank the shape', () => {
  assert.equal(classify('owner-shaped', 'fine-grained PAT', true, false)[0], 'saml-enforcement');
  assert.equal(classify('owner-shaped', 'fine-grained PAT', false, true)[0],
    'oauth-app-restriction');
  assert.equal(oauthWording('has enabled OAuth App access restrictions'), true);
  assert.equal(oauthWording('Resource not accessible by personal access token'), false);
});

test('a classic token is never sent to the approval queue', () => {
  assert.equal(classify('owner-shaped', 'classic PAT', false, false)[0],
    'not-a-fine-grained-token');
});

test('the permissions header is stated not to be the discriminator', () => {
  assert.ok(headerIsNotTheDiscriminator().includes('never what the token holds'));
});

test('the repair prints the approval and forbids a second token', () => {
  const fix = repair('pending-org-approval', 'acme-corp');
  assert.ok(fix.includes('an owner of acme-corp approves the waiting request'));
  assert.ok(fix.includes('does not approve it and does not ask for it'));
  assert.ok(fix.includes('Do not create a replacement token'));
});

test('the pending request is matched on a public login', () => {
  const pending = [{
    id: 42, owner: { login: 'Dana' }, repository_selection: 'all',
    created_at: '2026-08-25T09:00:00Z',
  }];
  assert.equal(findRequest(pending, 'dana').id, 42);
  assert.equal(findRequest(pending, 'someone-else'), null);
  assert.equal(daysPending('2026-08-25T09:00:00Z', NOW), 6);
  assert.equal(daysPending(null, NOW), null);
});

test('the credential type comes from its prefix', () => {
  assert.equal(tokenKind('github_pat_x'), 'fine-grained PAT');
  assert.equal(tokenKind('nope'), 'unknown');
});

test('the run costs six reads or seven', () => {
  assert.equal(readCost(), 6);
  assert.equal(readCost(true), 7);
});

FAQ

How is this different from a fine-grained token that is missing a permission?

By the shape of what fails. A missing permission is endpoint-shaped: the token cannot read issues, and it cannot read issues anywhere, including on repositories the account owns. A pending approval is owner-shaped: repositories, issues and members are all refused under the organization and all succeed personally. The <a href="/github/resource-not-accessible-by-pat/">permission note</a> is where you go once the shape says endpoint; this one is where you go when it says owner. Same status code, same message, opposite repairs.

The refusal names a permission my token already has. What is going on?

It names what the <em>endpoint</em> accepts. <code>x-accepted-github-permissions</code> is a property of the route, not of your credential, so it appears whether or not the permission is your problem, and it never states what the token holds. That is why it cannot settle this case, and why people lose an hour comparing it against a settings page that already agrees with it. The script prints that sentence in its output deliberately, because the header is the most convincing wrong lead in the whole diagnosis.

Why did creating a new token make it worse?

Because creating a token against an organization that requires approval files a new request. The old one is still in the queue, yours is now behind it, and an owner opening the page sees two entries from the same person for what looks like the same thing. Nothing about a fresh token shortens the wait, and each attempt makes the queue harder for the person who has to act on it. There is nothing to resubmit: the request that matters already exists.

Can the script request approval, or nudge the owner?

No. It detects the pending state and prints the step; it does not approve, request or re-request anything, and that restraint is the design rather than a limitation. The request is already filed, so the useful action is human and social — tell an owner it is waiting. What the script contributes to that conversation is a sentence with a number in it: this token has been waiting six days, here is where the approval lives. That is considerably more actionable than &ldquo;the API is returning 403&rdquo;.

Is this the same as a GitHub App waiting to be installed?

No, and they are easy to confuse because both end in an owner clicking approve. This is a personal access token filed under an organization's token policy: the credential exists, belongs to a person, and is powerless until approved. An App awaiting installation is an application that has no credential for that organization at all yet, and its diagnosis runs through the App's own installation endpoints rather than through a token's behaviour. The scripts read different things because there are different things to read.

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.