Skip to content

Diagnostic GitHub API

requests go out anonymous and are capped at 60 an hour

It works on the first run and dies on the fourth. On a laptop it survives a couple of minutes; in CI, behind a shared NAT address, it is refused almost immediately and the run before yours gets the blame. Nothing in the code is wrong. The token simply is not arriving, and GitHub does not treat that as an error. It serves you anyway, as a stranger, from a bucket sixty deep.

Read-only token Python and Node.js Tests included
A calculator sitting on top of a pile of money
Photo by Jakub Żerdzicki on Unsplash
The short answer

One number settles it. GET /rate_limit returns resources.core.limit, and that value is 60 for an anonymous request and 5000 or more for an authenticated one. The same value is on x-ratelimit-limit for any response, so it costs one round trip. Corroborate with GET /user: anonymous gets 401 {"message":"Requires authentication"}, authenticated gets your login.

This is not a quota problem and it does not get better by spending less. It is an identity problem: the request went out without credentials, so GitHub applied the anonymous tier, which is 60 an hour per originating IP address rather than per script. Everything else on that address shares it.

The problem in plain words

The silence is the whole difficulty. Send a bad token and you get 401 immediately, which is a clear error with an obvious repair. Send no token and you get 200, with real data, for a while. The failure is deferred until the sixty-first request, and by then the code has moved on and the traceback points at whatever call happened to be unlucky.

The message names an IP address rather than a user: "API rate limit exceeded for 20.51.0.14." That is easy to skim past as the same rate-limit error everyone knows, and it is the opposite one. A user ID in that message means the token arrived and its hourly quota is spent. An IP address means no token arrived at all.

The paths that produce it are boring, which is why they survive review. An environment variable that is exported in the shell but not in the container. A CI secret that is not exposed to pull requests from forks and resolves to an empty string. A value pasted with the surrounding quotes still attached. A client library whose default is to construct itself with no auth when the variable is missing, rather than to refuse. A copy of the Bearer prefix pasted into the variable as well as into the header.

Variableresolves emptyset, but tonothingHeader omittedclient carries onServedanonymously200 with real dataRequest 61refused403 names an IPBlamed on therunnershared egressaddress
An invalid token fails on request one. An absent token fails on request sixty-one, somewhere else entirely.

Why it happens

Anonymous is a supported tier, not a failure. Most of the REST API serves public data without credentials, so a request with no Authorization header is a legitimate request. GitHub answers it from the 60-an-hour bucket and moves on. Nothing warns you, because from the server's point of view nothing went wrong.

The bucket is keyed on the address, not on you. Sixty per hour per originating IP. A laptop has that address to itself; a CI runner, a NAT gateway or a shared egress proxy does not, so the sixty is divided between every job on it and the effective allowance is whatever is left over. This is why the same code fails in minutes on CI and takes an hour to fail locally.

An empty variable is not the same as a missing one, and both read as false. GITHUB_TOKEN="" is set. os.environ.get("GITHUB_TOKEN") returns "", the if not token guard fires, and the message says "not set" when the truth is "set to nothing". They have different repairs, and telling them apart is the first thing a check should do.

The limit value names the tier. 60 is anonymous. 5,000 is an authenticated user, an OAuth token or the floor for a GitHub App installation. 15,000 is a user on Enterprise Cloud. Up to 12,500 is an App installation that has scaled with installed repositories and users. The one boundary that is unambiguous is 60 against anything larger, which is the only one this check needs.

A token can be present and still not work. If the variable holds a well-formed token and GET /user still answers 401, the token is expired, revoked, or something between your process and GitHub stripped the header. Those are different from "no token" and should not be reported as the same thing.

The fix, as a flow

Nothing here is about quota. The script proves what GitHub thinks you are, by asking the same free endpoint twice, once with the header and once deliberately without it. Two numbers that agree are the proof that the header is not arriving.

Limit with and without theheaderplus GET /userBoth report 60the header is not arrivingVariable unset or emptysame symptom, different repairSent, but /user says 401expired or stripped, not missing5,000 against a control of 60authenticated
The control request carries no credentials on purpose. Without it you have one number and a theory.

How to fix it

Read the shape of the variable before you send it anywhere

Unset, empty, whitespace only, wrapped in quotes, carrying a Bearer or token prefix that belongs in the header instead, or still holding the placeholder from the example file. Each of those is a different fix and all of them are visible locally, for free, before a single request goes out. Report a fingerprint — the recognised prefix and the length — and never the value.

Ask GET /rate_limit what tier you are in

resources.core.limit of 60 is proof of anonymity; anything above it is proof of authentication. The endpoint does not consume quota, so this check is free even when you are nearly out of it, and it works with any token including one whose scopes are empty.

Send the same request without the header as a control

This is the step that turns a number into an argument. Call /rate_limit a second time with no Authorization header at all and compare. If the two calls report the same limit, the header you thought you were sending is not reaching GitHub. If they differ, it is.

Corroborate with GET /user

401 Requires authentication and a limit of 60 agree with each other: anonymous. A login and a limit of 5,000 agree the other way. A well-formed token with a 401 is the interesting third case — the token exists and GitHub rejected it, which is expiry, revocation or a stripped header, not a missing variable.

Make anonymous access impossible rather than merely unlikely

Assert at startup that the limit is above 60 and exit if it is not. Three lines, once, at the top of the process. The reason this beats checking that the variable is non-empty is that it survives everything in between: the header that was built wrong, the client that silently dropped it, the proxy that stripped it. It checks the thing you actually care about, which is what GitHub thinks you are.

How to check it worked

Run the check with the token in place. The authenticated limit and the anonymous control should disagree, and they should disagree by a lot.

python3 github_auth_tier_check.py
# authenticated: limit 5000 against an anonymous control of 60, as ghp_ (40 chars)

The full code

The interesting work happens before the network. One pure function inspects the environment variable's shape without ever returning its value, one maps a limit to a tier, and one combines those with two status codes into a verdict that distinguishes "no token" from "a token GitHub refused". The three requests are all GETs, two of them to /rate_limit, which costs nothing — and one of those deliberately carries no credentials, because a control is the only way to prove the header is missing rather than merely suspected.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_auth_tier_check.py
"""Prove which authentication tier your requests are actually in.

Read only. Three GETs: /rate_limit with the token, /rate_limit without it as a
control, and /user. GET /rate_limit does not count against the primary rate
limit, so the check is free in both tiers.

The token is read from the environment and never printed. What comes out is a
fingerprint: the recognised prefix and the length, which is enough to say "this
is a classic personal access token of the usual size" and not enough to use.
"""
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_auth_tier_check")

API = "https://api.github.com"
UA = "github-auth-tier-check/1.0"

ANON_LIMIT = 60

# Prefixes GitHub issues. Recognising one is not proof the token is valid; it is
# only evidence that the variable holds a token rather than a path, a URL or the
# placeholder somebody left in the example file.
PREFIXES = {
    "ghp_": "classic personal access token",
    "github_pat_": "fine-grained personal access token",
    "gho_": "OAuth app user token",
    "ghu_": "GitHub App user-to-server token",
    "ghs_": "GitHub App installation token",
    "ghr_": "GitHub App refresh token",
    "eyJ": "JSON Web Token, signed as a GitHub App",
}

PLACEHOLDERS = ("your", "xxx", "<", ">", "changeme", "replace", "example",
                "placeholder", "dummy", "here", "todo")


def inspect_secret(raw):
    """Describe the environment variable without disclosing it. Pure.

    Returns {"fingerprint", "kind", "problems"}. The distinction that matters
    most is unset against empty: both fail an `if not token` guard, both get
    reported as "not set", and they have different repairs. One is a missing
    export, the other is an export whose value did not survive.
    """
    problems = []
    if raw is None:
        return {"fingerprint": "absent", "kind": None, "problems": ["unset"]}
    if raw == "":
        return {"fingerprint": "empty string", "kind": None, "problems": ["empty"]}

    value = raw.strip()
    if not value:
        return {"fingerprint": "whitespace only", "kind": None, "problems": ["blank"]}
    if value != raw:
        problems.append("padded")

    if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
        problems.append("quoted")
        value = value[1:-1].strip()

    lowered = value.lower()
    for scheme in ("bearer ", "token "):
        if lowered.startswith(scheme):
            problems.append("scheme-included")
            value = value[len(scheme):].strip()
            lowered = value.lower()
            break

    if any(c.isspace() for c in value):
        problems.append("contains-whitespace")

    kind = None
    for prefix, name in PREFIXES.items():
        if value.startswith(prefix):
            kind = name
            break

    if kind is None:
        problems.append("unknown-prefix")
        # Only look for placeholder wording once the prefix has already failed,
        # so a real token that happens to contain "xxx" is not accused.
        if any(marker in lowered for marker in PLACEHOLDERS):
            problems.append("placeholder")

    prefix_shown = next((p for p in PREFIXES if value.startswith(p)), "unrecognised")
    return {"fingerprint": "%s (%d chars)" % (prefix_shown, len(value)),
            "kind": kind, "problems": problems}


def tier_from_limit(limit):
    """Name the tier a core limit belongs to. Pure.

    Only one boundary here is unambiguous, and it is the one that matters: 60
    against anything larger. The rest is useful colour and is labelled as such,
    because 5,000 is both an authenticated user and the floor for an App
    installation, and the API does not disambiguate them here.
    """
    try:
        limit = int(limit)
    except (TypeError, ValueError):
        return ("unknown", "no core limit was reported")

    if limit <= 0:
        return ("unknown", "a core limit of %d is not a tier" % limit)
    if limit <= ANON_LIMIT:
        return ("anonymous",
                "a core limit of %d is the anonymous tier, which is counted per "
                "originating IP address and shared with everything else on it"
                % limit)
    if limit == 5000:
        return ("authenticated",
                "5000 an hour: an authenticated user, an OAuth token, or a "
                "GitHub App installation that has not scaled beyond the floor")
    if limit == 15000:
        return ("enterprise",
                "15000 an hour: a user on GitHub Enterprise Cloud")
    if limit > 5000:
        return ("scaled",
                "%d an hour, above the 5000 floor: a GitHub App installation "
                "whose limit has grown with installed repositories and users"
                % limit)
    return ("authenticated", "%d an hour, which is above the anonymous 60" % limit)


def diagnose(authed_limit, anon_limit, user_status, secret):
    """Combine the local inspection and the two probes into one verdict. Pure.

    "No token" and "a token GitHub refused" both end in anonymous behaviour and
    they are not the same incident, so they do not get the same state.
    """
    secret = secret or {"problems": ["unset"], "fingerprint": "absent"}
    problems = secret.get("problems") or []
    tier, note = tier_from_limit(authed_limit)
    anon_tier, _ = tier_from_limit(anon_limit)

    if any(p in problems for p in ("unset", "empty", "blank")):
        return ("no-token",
                "GITHUB_TOKEN is %s, so every request goes out anonymous at 60 "
                "an hour per IP address. This is not a quota problem and "
                "spending less will not help it."
                % {"unset": "not set", "empty": "set to an empty string",
                   "blank": "whitespace only"}[problems[0]])

    if tier == "anonymous":
        if anon_tier == "anonymous":
            detail = ("the token was sent and GitHub still reports %s. The "
                      "control request without any header reports the same, so "
                      "the header is not arriving." % note)
        else:
            detail = note
        extra = ""
        if "scheme-included" in problems:
            extra = (" The variable itself starts with a scheme word, so the "
                     "header was probably built as \"Bearer Bearer ...\".")
        elif "quoted" in problems:
            extra = (" The variable still has its surrounding quotes, which "
                     "become part of the header value.")
        elif "padded" in problems or "contains-whitespace" in problems:
            extra = (" The variable carries whitespace, which is enough to "
                     "make the header invalid.")
        return ("anonymous", detail + extra)

    if user_status == 401:
        return ("token-rejected",
                "the variable holds %s but GET /user answered 401. The token "
                "is expired, revoked, or the header was removed between here "
                "and GitHub. That is not the same as a missing token."
                % (secret.get("kind") or "an unrecognised value"))

    if user_status == 403:
        return ("blocked",
                "authenticated at %s, but GET /user answered 403. Look at org "
                "SSO authorisation and IP allow lists rather than at the tier."
                % note)

    if user_status == 200:
        return ("authenticated",
                "%s. The anonymous control reports %s, so the header is "
                "arriving." % (note, anon_limit))

    return ("unclear",
            "core limit says %s but GET /user answered %s, so the two probes "
            "do not agree. Treat the limit as the more reliable of the two."
            % (note, user_status))


def get(url, token=None):
    """One GET. Returns (status, body-or-None, headers)."""
    headers = {"Accept": "application/vnd.github+json",
               "X-GitHub-Api-Version": "2022-11-28",
               "User-Agent": UA}
    if token:
        headers["Authorization"] = "Bearer " + token
    try:
        r = requests.get(url, headers=headers, timeout=30)
    except requests.RequestException as exc:
        log.error("%s failed: %s", url, exc)
        return (0, None, {})
    try:
        body = r.json()
    except ValueError:
        body = None
    return (r.status_code, body, dict(r.headers))


def core_limit(body):
    return ((body or {}).get("resources", {}).get("core") or {}).get("limit")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--env", default="GITHUB_TOKEN",
                    help="environment variable holding the token")
    args = ap.parse_args()

    raw = os.environ.get(args.env)
    secret = inspect_secret(raw)
    log.info("%s: %s%s", args.env, secret["fingerprint"],
             ", " + secret["kind"] if secret["kind"] else "")
    for problem in secret["problems"]:
        log.warning("  variable problem: %s", problem)

    token = (raw or "").strip().strip("\"'").strip()
    for scheme in ("Bearer ", "bearer ", "token ", "Token "):
        if token.startswith(scheme):
            token = token[len(scheme):].strip()
            break

    authed_status, authed_body, authed_headers = get(API + "/rate_limit", token or None)
    anon_status, anon_body, _ = get(API + "/rate_limit")
    user_status, user_body, _ = get(API + "/user", token or None)

    authed = core_limit(authed_body) if authed_status == 200 else None
    anon = core_limit(anon_body) if anon_status == 200 else None
    log.info("with the token:    core limit %s", authed)
    log.info("control, no token: core limit %s", anon)
    log.info("GET /user:         %s%s", user_status,
             " as " + str((user_body or {}).get("login")) if user_status == 200 else "")

    scopes = {k.lower(): v for k, v in authed_headers.items()}.get("x-oauth-scopes")
    if scopes is not None:
        log.info("x-oauth-scopes is present (%r), so this is a classic token or "
                 "an OAuth token rather than a fine-grained one",
                 scopes if scopes else "empty")

    state, detail = diagnose(authed, anon, user_status, secret)
    log.info("%s: %s", state, detail)

    if state != "authenticated":
        log.info("repair: export the token where the process can see it. In a "
                 "container that means passing it in, not exporting it in the "
                 "shell that ran the build.")
        log.info("repair: paste the value only. No surrounding quotes, no "
                 "Bearer prefix, no trailing newline from the file it came out "
                 "of.")
        log.info("repair: assert the tier at startup rather than asserting the "
                 "variable is non-empty. Add this to the top of the process:")
        log.info("  limit = get('%s/rate_limit').json()['resources']['core']"
                 "['limit']", API)
        log.info("  if limit <= %d: raise SystemExit('unauthenticated: refusing "
                 "to run at 60 requests an hour')", ANON_LIMIT)

    print(json.dumps({"state": state, "fingerprint": secret["fingerprint"],
                      "problems": secret["problems"],
                      "authenticated_limit": authed, "anonymous_limit": anon,
                      "user_status": user_status,
                      "tier": tier_from_limit(authed)[0]}, indent=2))
    return 0 if state == "authenticated" else 1


if __name__ == "__main__":
    sys.exit(main())
github-auth-tier-check.mjs
/**
 * Prove which authentication tier your requests are actually in.
 *
 * Read only. Three GETs: /rate_limit with the token, /rate_limit without it as
 * a control, and /user. GET /rate_limit does not count against the primary
 * rate limit, so the check is free in both tiers.
 *
 * The token is read from the environment and never printed.
 */
const API = 'https://api.github.com';
const UA = 'github-auth-tier-check/1.0';

export const ANON_LIMIT = 60;

// Recognising a prefix is not proof the token is valid; it is evidence that the
// variable holds a token rather than a path, a URL or a leftover placeholder.
const PREFIXES = {
  ghp_: 'classic personal access token',
  github_pat_: 'fine-grained personal access token',
  gho_: 'OAuth app user token',
  ghu_: 'GitHub App user-to-server token',
  ghs_: 'GitHub App installation token',
  ghr_: 'GitHub App refresh token',
  eyJ: 'JSON Web Token, signed as a GitHub App',
};

const PLACEHOLDERS = ['your', 'xxx', '<', '>', 'changeme', 'replace', 'example',
  'placeholder', 'dummy', 'here', 'todo'];

/**
 * Describe the environment variable without disclosing it. Pure.
 * Unset and empty are different findings with different repairs, even though
 * both fail the same falsy check.
 */
export function inspectSecret(raw) {
  const problems = [];
  if (raw === null || raw === undefined) {
    return { fingerprint: 'absent', kind: null, problems: ['unset'] };
  }
  if (raw === '') return { fingerprint: 'empty string', kind: null, problems: ['empty'] };

  let value = raw.trim();
  if (!value) return { fingerprint: 'whitespace only', kind: null, problems: ['blank'] };
  if (value !== raw) problems.push('padded');

  if (value.length >= 2 && value[0] === value[value.length - 1] && '"\''.includes(value[0])) {
    problems.push('quoted');
    value = value.slice(1, -1).trim();
  }

  let lowered = value.toLowerCase();
  for (const scheme of ['bearer ', 'token ']) {
    if (lowered.startsWith(scheme)) {
      problems.push('scheme-included');
      value = value.slice(scheme.length).trim();
      lowered = value.toLowerCase();
      break;
    }
  }

  if (/\s/.test(value)) problems.push('contains-whitespace');

  let kind = null;
  for (const [prefix, name] of Object.entries(PREFIXES)) {
    if (value.startsWith(prefix)) { kind = name; break; }
  }

  if (kind === null) {
    problems.push('unknown-prefix');
    // Only after the prefix has already failed, so a real token containing
    // "xxx" by chance is not accused of being a placeholder.
    if (PLACEHOLDERS.some((m) => lowered.includes(m))) problems.push('placeholder');
  }

  const shown = Object.keys(PREFIXES).find((p) => value.startsWith(p)) ?? 'unrecognised';
  return { fingerprint: `${shown} (${value.length} chars)`, kind, problems };
}

/**
 * Name the tier a core limit belongs to. Pure.
 * Only the 60-against-anything-larger boundary is unambiguous; the rest is
 * colour, and 5,000 genuinely means two different things.
 */
export function tierFromLimit(limit) {
  const n = Number.parseInt(limit, 10);
  if (!Number.isFinite(n)) return ['unknown', 'no core limit was reported'];
  if (n <= 0) return ['unknown', `a core limit of ${n} is not a tier`];
  if (n <= ANON_LIMIT) {
    return ['anonymous',
      `a core limit of ${n} is the anonymous tier, which is counted per ` +
      'originating IP address and shared with everything else on it'];
  }
  if (n === 5000) {
    return ['authenticated',
      '5000 an hour: an authenticated user, an OAuth token, or a GitHub App ' +
      'installation that has not scaled beyond the floor'];
  }
  if (n === 15000) return ['enterprise', '15000 an hour: a user on GitHub Enterprise Cloud'];
  if (n > 5000) {
    return ['scaled',
      `${n} an hour, above the 5000 floor: a GitHub App installation whose ` +
      'limit has grown with installed repositories and users'];
  }
  return ['authenticated', `${n} an hour, which is above the anonymous 60`];
}

/**
 * Combine the local inspection and the two probes into one verdict. Pure.
 * "No token" and "a token GitHub refused" are not the same incident.
 */
export function diagnose(authedLimit, anonLimit, userStatus, secret) {
  const s = secret ?? { problems: ['unset'], fingerprint: 'absent' };
  const problems = s.problems ?? [];
  const [tier, note] = tierFromLimit(authedLimit);
  const [anonTier] = tierFromLimit(anonLimit);

  const missing = ['unset', 'empty', 'blank'].find((p) => problems.includes(p));
  if (missing) {
    const said = { unset: 'not set', empty: 'set to an empty string', blank: 'whitespace only' };
    return ['no-token',
      `GITHUB_TOKEN is ${said[missing]}, so every request goes out anonymous ` +
      'at 60 an hour per IP address. This is not a quota problem and spending ' +
      'less will not help it.'];
  }

  if (tier === 'anonymous') {
    let detail = note;
    if (anonTier === 'anonymous') {
      detail = 'the token was sent and GitHub still reports ' + note +
        '. The control request without any header reports the same, so the ' +
        'header is not arriving.';
    }
    let extra = '';
    if (problems.includes('scheme-included')) {
      extra = ' The variable itself starts with a scheme word, so the header ' +
        'was probably built as "Bearer Bearer ...".';
    } else if (problems.includes('quoted')) {
      extra = ' The variable still has its surrounding quotes, which become ' +
        'part of the header value.';
    } else if (problems.includes('padded') || problems.includes('contains-whitespace')) {
      extra = ' The variable carries whitespace, which is enough to make the ' +
        'header invalid.';
    }
    return ['anonymous', detail + extra];
  }

  if (userStatus === 401) {
    return ['token-rejected',
      `the variable holds ${s.kind ?? 'an unrecognised value'} but GET /user ` +
      'answered 401. The token is expired, revoked, or the header was removed ' +
      'between here and GitHub. That is not the same as a missing token.'];
  }

  if (userStatus === 403) {
    return ['blocked',
      `authenticated at ${note}, but GET /user answered 403. Look at org SSO ` +
      'authorisation and IP allow lists rather than at the tier.'];
  }

  if (userStatus === 200) {
    return ['authenticated',
      `${note}. The anonymous control reports ${anonLimit}, so the header is arriving.`];
  }

  return ['unclear',
    `core limit says ${note} but GET /user answered ${userStatus}, so the two ` +
    'probes do not agree. Treat the limit as the more reliable of the two.'];
}

async function get(url, token) {
  const headers = {
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
  if (token) headers.Authorization = `Bearer ${token}`;
  try {
    const res = await fetch(url, { headers });
    let body = null;
    try { body = await res.json(); } catch { body = null; }
    return [res.status, body, Object.fromEntries(res.headers.entries())];
  } catch (err) {
    console.error(`${url} failed: ${err.message}`);
    return [0, null, {}];
  }
}

const coreLimit = (body) => body?.resources?.core?.limit;

async function main() {
  const name = process.argv[2] ?? 'GITHUB_TOKEN';
  const raw = process.env[name];
  const secret = inspectSecret(raw);
  console.log(`${name}: ${secret.fingerprint}${secret.kind ? ', ' + secret.kind : ''}`);
  for (const problem of secret.problems) console.warn(`  variable problem: ${problem}`);

  let token = (raw ?? '').trim().replace(/^["']|["']$/g, '').trim();
  for (const scheme of ['Bearer ', 'bearer ', 'token ', 'Token ']) {
    if (token.startsWith(scheme)) { token = token.slice(scheme.length).trim(); break; }
  }

  const [authedStatus, authedBody, authedHeaders] = await get(`${API}/rate_limit`, token || null);
  const [anonStatus, anonBody] = await get(`${API}/rate_limit`);
  const [userStatus, userBody] = await get(`${API}/user`, token || null);

  const authed = authedStatus === 200 ? coreLimit(authedBody) : null;
  const anon = anonStatus === 200 ? coreLimit(anonBody) : null;
  console.log(`with the token:    core limit ${authed}`);
  console.log(`control, no token: core limit ${anon}`);
  console.log(`GET /user:         ${userStatus}${userStatus === 200 ? ' as ' + userBody?.login : ''}`);

  const lowered = {};
  for (const [k, v] of Object.entries(authedHeaders)) lowered[k.toLowerCase()] = v;
  if (lowered['x-oauth-scopes'] !== undefined) {
    console.log(`x-oauth-scopes is present (${lowered['x-oauth-scopes'] || 'empty'}), so ` +
      'this is a classic token or an OAuth token rather than a fine-grained one');
  }

  const [state, detail] = diagnose(authed, anon, userStatus, secret);
  console.log(`${state}: ${detail}`);

  if (state !== 'authenticated') {
    console.log('repair: export the token where the process can see it. In a ' +
      'container that means passing it in, not exporting it in the shell that ' +
      'ran the build.');
    console.log('repair: paste the value only. No surrounding quotes, no Bearer ' +
      'prefix, no trailing newline from the file it came out of.');
    console.log('repair: assert the tier at startup rather than asserting the ' +
      'variable is non-empty:');
    console.log("  const { resources } = await (await fetch(`${API}/rate_limit`, { headers })).json();");
    console.log(`  if (resources.core.limit <= ${ANON_LIMIT}) throw new Error('unauthenticated');`);
  }

  console.log(JSON.stringify({
    state, fingerprint: secret.fingerprint, problems: secret.problems,
    authenticated_limit: authed, anonymous_limit: anon,
    user_status: userStatus, tier: tierFromLimit(authed)[0],
  }, null, 2));
  process.exitCode = state === 'authenticated' ? 0 : 1;
}

// Only run when invoked directly, so importing this from the test file does not
// start main() and set an exit code the tests never asked for.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The environment variable is where this goes wrong, so that is where the tests are. Unset and empty are separate cases because they have separate repairs and the same falsy check swallows both. A value wrapped in quotes, a value carrying its own Bearer, a value that is still the placeholder from the example file: each of those is a real thing someone has shipped. And two cases exist to stop the check overreaching — a real token must not be accused of being a placeholder because it happens to contain three letters, and a token GitHub actively rejected must not be reported as a token that was never sent.

test_github_auth_tier_check.py
from github_auth_tier_check import inspect_secret, tier_from_limit, diagnose

GOOD = {"fingerprint": "ghp_ (40 chars)", "kind": "classic personal access token",
        "problems": []}


def test_unset_and_empty_are_not_the_same_finding():
    assert inspect_secret(None)["problems"] == ["unset"]
    assert inspect_secret("")["problems"] == ["empty"]
    assert inspect_secret("   ")["problems"] == ["blank"]


def test_a_normal_token_reports_a_fingerprint_and_no_problems():
    got = inspect_secret("ghp_" + "A" * 36)
    assert got["problems"] == []
    assert got["kind"] == "classic personal access token"
    assert got["fingerprint"] == "ghp_ (40 chars)"


def test_the_fingerprint_never_contains_the_token():
    secret = "ghp_" + "S3CR3T" * 6
    got = inspect_secret(secret)
    assert "S3CR3T" not in got["fingerprint"]
    assert secret not in repr(got)


def test_a_fine_grained_token_is_recognised():
    assert inspect_secret("github_pat_" + "B" * 60)["kind"].startswith("fine-grained")


def test_an_app_installation_token_is_recognised():
    assert "installation" in inspect_secret("ghs_" + "C" * 36)["kind"]


def test_surrounding_quotes_survived_the_paste():
    got = inspect_secret('"ghp_' + "A" * 36 + '"')
    assert "quoted" in got["problems"]
    assert got["kind"] == "classic personal access token"


def test_the_scheme_word_ended_up_in_the_variable():
    got = inspect_secret("Bearer ghp_" + "A" * 36)
    assert "scheme-included" in got["problems"]
    assert got["kind"] == "classic personal access token"
    assert "scheme-included" in inspect_secret("token ghp_x")["problems"]


def test_a_trailing_newline_from_a_file_is_reported():
    assert "padded" in inspect_secret("ghp_" + "A" * 36 + "\n")["problems"]


def test_the_placeholder_from_the_example_file_is_caught():
    got = inspect_secret("your_token_here")
    assert "unknown-prefix" in got["problems"]
    assert "placeholder" in got["problems"]


def test_a_real_token_is_never_accused_of_being_a_placeholder():
    # Placeholder wording is only looked for once the prefix has failed, so a
    # legitimate token containing "xxx" by chance stays clean.
    got = inspect_secret("ghp_xxx" + "A" * 33)
    assert got["problems"] == []


def test_sixty_is_the_only_boundary_that_matters():
    assert tier_from_limit(60)[0] == "anonymous"
    assert tier_from_limit(5000)[0] == "authenticated"
    assert tier_from_limit(15000)[0] == "enterprise"
    assert tier_from_limit(12500)[0] == "scaled"
    assert tier_from_limit(None)[0] == "unknown"


def test_five_thousand_is_reported_as_ambiguous_rather_than_as_a_user():
    _, note = tier_from_limit(5000)
    assert "App installation" in note


def test_a_missing_variable_is_named_as_such():
    state, detail = diagnose(60, 60, 401, inspect_secret(None))
    assert state == "no-token"
    assert "not set" in detail


def test_a_token_that_is_present_but_not_arriving_is_a_different_state():
    state, detail = diagnose(60, 60, 401, GOOD)
    assert state == "anonymous"
    assert "not arriving" in detail


def test_the_quoting_problem_is_named_in_the_anonymous_verdict():
    secret = inspect_secret('"ghp_' + "A" * 36 + '"')
    _, detail = diagnose(60, 60, 401, secret)
    assert "surrounding quotes" in detail


def test_a_rejected_token_is_not_reported_as_a_missing_one():
    state, detail = diagnose(5000, 60, 401, GOOD)
    assert state == "token-rejected"
    assert "expired" in detail


def test_a_403_points_at_sso_rather_than_at_the_tier():
    state, detail = diagnose(5000, 60, 403, GOOD)
    assert state == "blocked"
    assert "SSO" in detail


def test_the_healthy_case_cites_the_control():
    state, detail = diagnose(5000, 60, 200, GOOD)
    assert state == "authenticated"
    assert "control reports 60" in detail
github-auth-tier-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { inspectSecret, tierFromLimit, diagnose } from './github-auth-tier-check.mjs';

const GOOD = {
  fingerprint: 'ghp_ (40 chars)',
  kind: 'classic personal access token',
  problems: [],
};

test('unset and empty are not the same finding', () => {
  assert.deepEqual(inspectSecret(undefined).problems, ['unset']);
  assert.deepEqual(inspectSecret('').problems, ['empty']);
  assert.deepEqual(inspectSecret('   ').problems, ['blank']);
});

test('a normal token reports a fingerprint and no problems', () => {
  const got = inspectSecret('ghp_' + 'A'.repeat(36));
  assert.deepEqual(got.problems, []);
  assert.equal(got.kind, 'classic personal access token');
  assert.equal(got.fingerprint, 'ghp_ (40 chars)');
});

test('the fingerprint never contains the token', () => {
  const secret = 'ghp_' + 'S3CR3T'.repeat(6);
  const got = inspectSecret(secret);
  assert.ok(!got.fingerprint.includes('S3CR3T'));
  assert.ok(!JSON.stringify(got).includes(secret));
});

test('a fine-grained token is recognised', () => {
  assert.match(inspectSecret('github_pat_' + 'B'.repeat(60)).kind, /^fine-grained/);
});

test('an app installation token is recognised', () => {
  assert.match(inspectSecret('ghs_' + 'C'.repeat(36)).kind, /installation/);
});

test('surrounding quotes survived the paste', () => {
  const got = inspectSecret(`"ghp_${'A'.repeat(36)}"`);
  assert.ok(got.problems.includes('quoted'));
  assert.equal(got.kind, 'classic personal access token');
});

test('the scheme word ended up in the variable', () => {
  const got = inspectSecret('Bearer ghp_' + 'A'.repeat(36));
  assert.ok(got.problems.includes('scheme-included'));
  assert.equal(got.kind, 'classic personal access token');
  assert.ok(inspectSecret('token ghp_x').problems.includes('scheme-included'));
});

test('a trailing newline from a file is reported', () => {
  assert.ok(inspectSecret('ghp_' + 'A'.repeat(36) + '\n').problems.includes('padded'));
});

test('the placeholder from the example file is caught', () => {
  const got = inspectSecret('your_token_here');
  assert.ok(got.problems.includes('unknown-prefix'));
  assert.ok(got.problems.includes('placeholder'));
});

test('a real token is never accused of being a placeholder', () => {
  assert.deepEqual(inspectSecret('ghp_xxx' + 'A'.repeat(33)).problems, []);
});

test('sixty is the only boundary that matters', () => {
  assert.equal(tierFromLimit(60)[0], 'anonymous');
  assert.equal(tierFromLimit(5000)[0], 'authenticated');
  assert.equal(tierFromLimit(15000)[0], 'enterprise');
  assert.equal(tierFromLimit(12500)[0], 'scaled');
  assert.equal(tierFromLimit(null)[0], 'unknown');
});

test('five thousand is reported as ambiguous rather than as a user', () => {
  assert.match(tierFromLimit(5000)[1], /App installation/);
});

test('a missing variable is named as such', () => {
  const [state, detail] = diagnose(60, 60, 401, inspectSecret(undefined));
  assert.equal(state, 'no-token');
  assert.match(detail, /not set/);
});

test('a token that is present but not arriving is a different state', () => {
  const [state, detail] = diagnose(60, 60, 401, GOOD);
  assert.equal(state, 'anonymous');
  assert.match(detail, /not arriving/);
});

test('the quoting problem is named in the anonymous verdict', () => {
  const secret = inspectSecret(`"ghp_${'A'.repeat(36)}"`);
  const [, detail] = diagnose(60, 60, 401, secret);
  assert.match(detail, /surrounding quotes/);
});

test('a rejected token is not reported as a missing one', () => {
  const [state, detail] = diagnose(5000, 60, 401, GOOD);
  assert.equal(state, 'token-rejected');
  assert.match(detail, /expired/);
});

test('a 403 points at SSO rather than at the tier', () => {
  const [state, detail] = diagnose(5000, 60, 403, GOOD);
  assert.equal(state, 'blocked');
  assert.match(detail, /SSO/);
});

test('the healthy case cites the control', () => {
  const [state, detail] = diagnose(5000, 60, 200, GOOD);
  assert.equal(state, 'authenticated');
  assert.match(detail, /control reports 60/);
});

FAQ

How is this different from running out of my 5,000 an hour?

It is a different bucket and a different repair. Running out means the token arrived and spent its quota; the message names a user ID, x-ratelimit-limit reads 5000, and the fix is to make fewer requests. Being anonymous means no token arrived at all; the message names an IP address, x-ratelimit-limit reads 60, and making fewer requests only postpones it. Read the limit value and you never confuse the two again.

Why does it fail immediately on CI but take an hour on my laptop?

Because the anonymous bucket is counted per originating IP address. Your laptop usually has that address to itself. A CI runner shares an egress address with every other job on the fleet, so the sixty is spread across all of them and your share may be nothing at all. The same code, the same absent token, and a failure that arrives in seconds instead of an hour.

The variable is definitely set. Why is the limit still 60?

Set where, and visible to what. Exported in the shell that launched a container is not exported inside it. A CI secret is often not exposed to workflows triggered from a fork, and resolves to an empty string rather than failing. Beyond that, the value can be present and the header still wrong: quotes that came along with the paste, a Bearer prefix stored in the variable as well as added by the client, a newline from the file it was read out of. The control request settles which of the two it is.

Is it safe for the script to make a request with no token?

Yes, and it is the point of the check. It is a GET to /rate_limit, which serves anonymous callers and does not count against any bucket. Without that control you have one number and a theory; with it you have two numbers that either agree or disagree, and the disagreement is the proof that your header is arriving.

What should the code do when the token is missing?

Stop. The failure mode this note describes exists entirely because the sensible-looking alternative is to carry on without credentials, and every client library that does so turns a loud configuration error into a quiet one that surfaces sixty requests later in an unrelated place. Assert at startup that the reported core limit is above 60 and exit if it is not.

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.