Skip to content

Diagnostic GitHub API

the token is passed as an access_token query parameter

The call returns 401 {"message": "Requires authentication"} for an endpoint that plainly ought to work, and the token in the URL is correct — you can paste it into a header and watch the same call succeed. That is the small half of this. The large half is that the URL is now in an access log, a CI transcript, a browser history and a support ticket, and nothing about that half produces an error at all.

Read-only token Python and Node.js Tests included
White box on white table
Photo by Kelli McClintock on Unsplash
The short answer

GitHub removed the ?access_token= query parameter. A request carrying it is treated as unauthenticated, which means it silently falls to the 60-an-hour anonymous tier before it starts returning 401 on anything that requires a caller. Moving the credential into Authorization: Bearer TOKEN fixes the request.

It does not fix the disclosure, and the disclosure is the reason this note exists. A URL is the single most-copied string in any system: it is logged by your proxy, by GitHub, by every CDN in between, by the CI runner that echoed the command, and by the browser that opened it. Assume every token that has ever been in a URL is public. The script below audits URLs for credential-shaped values, reports their shape, location and a digest so you can correlate them, and prints a scrubbed URL to paste into the ticket. It never prints the value, and it never sends one.

The problem in plain words

The obvious detection is the one you should not run. The documented tell is that GET /rate_limit?access_token=TOKEN reports a limit of 60 instead of 5,000, which is true, and which requires you to send a live credential in a URL one more time in order to prove that sending it in a URL was a mistake. Every hop that logs it gets a fresh copy. If you want that reading, it belongs to the anonymous-tier note, and the way to get it there is with the header removed rather than with the token relocated.

The failure is also split in a way that hides it. Endpoints that do not require authentication keep working, because an unauthenticated request to a public repository is a perfectly valid request. So the integration half-works: public reads succeed, private ones 401, and the quota quietly shrinks by a factor of eighty. That reads like a permissions problem, and people go looking at scopes.

Then there is where these URLs come from. Almost nobody types ?access_token= deliberately in 2026. They inherit it: a code sample from 2015, a Postman collection, a webhook URL somebody parameterised, a wrapper that appends credentials for a different API and was pointed at this one. The string arrives already written, and the person deploying it has no reason to look at it.

access_token inURLinherited from asampleParameterignoredrequest goes outanonymousPublic readsstill workprivate ones 401URL writteneverywhereproxy, CI, historyHeader fixshipsthe log lines stay
The 401 is the small half. The large half is the step before it, which nothing anywhere reports as an error.

Why it happens

URLs are copied by design. A header is read by the server and forgotten. A URL is the identity of the request, so it is written into the access log, the error report, the trace span, the retry queue, the CI transcript and the browser's history, by components that are behaving correctly. There is no configuration that makes a query string as private as a header.

Removing it from the code does not remove it from the logs. This is the part that turns a config change into an incident: the fix stops new copies being made and does nothing about existing ones. A credential that was in a URL has to be revoked and re-minted, not merely relocated.

The degradation happens before the error. Because the parameter is ignored rather than rejected, the request is anonymous rather than refused. That is why the first symptom is usually a rate-limit surprise rather than a 401, and why the two notes sit next to each other: one is about the tier you fell to, this one is about how you got there.

Shape is enough to identify a credential, and it is safe to print. ghp_, gho_, ghu_, ghs_, ghr_ and github_pat_ are documented prefixes, and forty hex characters is the legacy form. Naming the shape and a truncated digest tells you which credential you are looking at and lets you match two occurrences to each other, without the report becoming another copy of the secret.

What a script cannot tell you. It cannot enumerate the places a URL has already been written, cannot read your proxy's retention policy, and cannot revoke anything from a read-only credential. It can tell you that the value in this URL is the value this process is holding, and that this value still authenticates, which is exactly enough to make revocation non-optional.

The fix, as a flow

The script audits strings you already have rather than making the leak again: it never puts a credential in a URL, not even to reproduce the documented anonymous tier reading. Findings carry a shape, a length and a truncated digest, so two sightings can be matched to each other and neither one is another copy of the secret.

Shape, digest, locationnever the valueMatches the live tokenrevoke first, then scrubCredential nobody claimsassume live until provenMatches a dead tokenhistorical, habit is notNothing credential shapedthe URLs are clean
Liveness is the only part of this the API can answer, and it answers it from a header. Everything else is arithmetic on digests.

How to fix it

Collect the URLs rather than the credentials

Grep the access log, the CI transcript and the configuration for api.github.com and feed the matching lines to the script. You are looking for the shape of a request, not for a secret, so this step involves reading logs you already have rather than extracting anything new.

Classify by shape and fingerprint, never by value

A truncated SHA-256 of the value is enough to say "this is the same credential as that one" across two log files, and is not enough to reconstruct it. Everything the report prints should survive being pasted into a ticket that a dozen people can read. Exempt the parameters that are legitimately forty hex characters — sha, head, base — or every commit id in the log gets reported as a legacy token and nobody reads the second page.

Ask whether the credential is still live

One GET /rate_limit with the credential in the header. A 200 means the value sitting in your logs still works, which converts this from a tidy-up into a revocation. A 401 means it is already dead and the finding is historical.

Move the credential into the header and redeploy

Authorization: Bearer TOKEN on every request. Check the wrapper as well as the call site: a client that appends credentials to the query string usually does it in one place, and that place is not where you are looking.

Revoke, re-mint, and scrub what you can reach

In that order. Revoke first, because scrubbing takes days and revocation takes seconds. Then re-mint, redeploy, and go through the log stores you control — and write down the ones you do not control, because those are the ones that make the revocation necessary rather than optional.

How to check it worked

Re-run over the same log after the fix. A clean pass reports nothing found, and reports it against a credential the script has confirmed is live, so a clean pass means the URLs are clean rather than the token being dead.

python3 github_token_in_url.py --from-file access.log
# credential in this process: sha256:4f2a1c9d0b77, still live
# scanned 812 url(s)
# no-credential-in-url: no query parameter carried a credential-shaped value

The full code

This script is defined as much by what it will not do. It never sends a request with a credential in the URL, not even to reproduce the documented 60-versus-5,000 reading, because that reading costs a fresh copy of the secret in every log between here and GitHub. It never prints a value: findings carry a shape, a length and a twelve-character digest. The one live request is GET /rate_limit with the credential in a header, and it answers a single question — is the thing in your logs still usable.

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_token_in_url.py
"""Find GitHub credentials sitting in URLs, without ever printing one.

Read only, and one request: GET /rate_limit with the credential in an
Authorization header. That call spends no quota and answers the only question
the API can answer here, which is whether the credential you are holding still
works.

Two things this deliberately does not do. It never issues a request with a
credential in the query string, not even to reproduce the documented
anonymous-tier reading, because doing so writes a fresh copy of the secret into
every log between here and GitHub. And it never emits a credential value: a
finding carries a shape, a length and a truncated digest, all of which are safe
to paste into a ticket.
"""
import argparse
import hashlib
import hmac
import json
import logging
import os
import re
import sys
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

import requests

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

API = "https://api.github.com"
UA = "github-token-in-url/1.0"

# Documented token prefixes. Naming the shape tells you which credential to
# revoke; it is not itself a secret.
PREFIXES = (
    ("github_pat_", "fine-grained-pat"),
    ("ghp_", "classic-pat"),
    ("gho_", "oauth-token"),
    ("ghu_", "app-user-token"),
    ("ghs_", "app-installation-token"),
    ("ghr_", "refresh-token"),
)
LEGACY_HEX = re.compile(r"^[0-9a-f]{40}$")

# Parameter names that carry a credential whatever the value looks like.
SUSPECT_NAMES = {"access_token", "token", "oauth_token", "api_key", "apikey",
                 "client_secret", "private_token", "auth", "password", "secret"}

# Parameter names whose values are legitimately forty hex characters. Without
# these, every commit SHA in a URL is reported as a legacy token and the report
# becomes noise nobody reads.
GIT_OBJECT_NAMES = {"sha", "commit_sha", "head_sha", "base_sha", "tree_sha",
                    "oid", "ref", "base", "head"}

URL_PATTERN = re.compile(r"https?://[^\s<>]+")

# Punctuation a log line puts after a URL. Stripped so a quoted URL does not
# carry its closing quote into the audit.
TRAILING = '"' + "'" + ">),.;"

REDACTED = "REDACTED"


def shape_of(value):
    """Name the kind of credential a string looks like. Pure.

    Returns a shape name, never the value. `opaque` means "long enough to be a
    secret and not in a documented form", which is worth reporting when it sits
    in a parameter called access_token.
    """
    text = str(value or "")
    for prefix, name in PREFIXES:
        if text.startswith(prefix):
            return name
    if LEGACY_HEX.match(text):
        return "legacy-hex40"
    return "opaque" if len(text) >= 16 else "short"


def fingerprint(value):
    """A twelve-character digest, for correlating two sightings. Pure.

    Truncated on purpose: enough to say "these two log lines carry the same
    credential", not enough to be a credential.
    """
    digest = hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()
    return "sha256:" + digest[:12]


def same_credential(left, right):
    """Do two fingerprints describe the same value? Pure."""
    if not left or not right:
        return False
    return hmac.compare_digest(str(left), str(right))


def urls_in(text):
    """Every URL in a blob of log or configuration. Pure."""
    return [u.rstrip(TRAILING) for u in URL_PATTERN.findall(str(text or ""))]


def is_credential(name, value):
    """Would this query parameter be reported as carrying a credential? Pure.

    The single place that decision is made, so the reporter and the redactor
    cannot drift apart and start disagreeing about what counts as a secret.
    """
    lowered = str(name or "").lower()
    if lowered in SUSPECT_NAMES:
        return True
    if lowered in GIT_OBJECT_NAMES:
        return False
    return shape_of(value) not in ("short", "opaque")


def credential_params(url):
    """Credential-bearing query parameters in one URL. Pure.

    A parameter is reported when its name is a known credential name or when
    its value is shaped like a token, so a credential hiding under a harmless
    name is still found. The value is never in the return.
    """
    try:
        parts = urlsplit(str(url or ""))
    except ValueError:
        return []
    out = []
    for name, value in parse_qsl(parts.query, keep_blank_values=True):
        if not is_credential(name, value):
            continue
        out.append({"param": name, "shape": shape_of(value), "length": len(value),
                    "fingerprint": fingerprint(value),
                    "ignored_by_github": name.lower() == "access_token"})
    return out


def redact(url):
    """The same URL with every credential-bearing value replaced. Pure.

    This is the artefact you paste into a ticket. Anything that would be
    reported by credential_params is replaced, because both ask is_credential.
    """
    try:
        parts = urlsplit(str(url or ""))
    except ValueError:
        return REDACTED
    if not parts.query:
        return str(url or "")
    pairs = [(name, REDACTED if is_credential(name, value) else value)
             for name, value in parse_qsl(parts.query, keep_blank_values=True)]
    return urlunsplit((parts.scheme, parts.netloc, parts.path,
                       urlencode(pairs), parts.fragment))


def audit(entries):
    """Findings across many labelled URLs, with the redacted form attached. Pure.

    entries: [(label, url), ...] where label is a line number, a file name or
    anything else that helps somebody find the copy again.
    """
    findings = []
    for label, url in entries or []:
        for hit in credential_params(url):
            item = dict(hit)
            item["where"] = label
            item["redacted"] = redact(url)
            findings.append(item)
    return findings


def verdict(findings, live, held_fingerprint):
    """Turn the findings into a decision about revocation. Pure."""
    if not findings:
        return ("no-credential-in-url",
                "no query parameter carried a credential-shaped value.")

    matched = [f for f in findings
               if same_credential(f.get("fingerprint"), held_fingerprint)]
    ignored = [f for f in findings if f.get("ignored_by_github")]
    distinct = len({f.get("fingerprint") for f in findings})

    tail = (" %d of them use access_token, which GitHub ignores outright, so "
            "those requests went out anonymous rather than authenticated."
            % len(ignored)) if ignored else ""

    if matched and live:
        return ("live-credential-in-url",
                "%d occurrence(s) of %d distinct credential(s) in URLs, and one "
                "of them is the credential this process is holding, which still "
                "authenticates. Revoke it; relocating it to a header does not "
                "unwrite the log lines.%s" % (len(findings), distinct, tail))
    if matched:
        return ("dead-credential-in-url",
                "%d occurrence(s) in URLs match the credential this process "
                "holds, and that credential no longer authenticates. The "
                "exposure is historical, but the habit that created it is "
                "not.%s" % (len(findings), tail))
    return ("credential-in-url",
            "%d occurrence(s) of %d distinct credential-shaped value(s) in "
            "URLs. None match the credential this process holds, so their "
            "liveness cannot be judged from here; treat them as live until "
            "somebody proves otherwise.%s" % (len(findings), distinct, tail))


def get(session, path):
    """One GET. Returns (status, json-or-None)."""
    url = API + path if path.startswith("/") else path
    r = session.get(url, timeout=30)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, None


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("urls", nargs="*", help="URLs to audit")
    ap.add_argument("--from-file",
                    help="a log or config file; every URL in it is audited")
    args = ap.parse_args()

    entries = [("argv[%d]" % i, u) for i, u in enumerate(args.urls, start=1)]
    if args.from_file:
        with open(args.from_file, "r", encoding="utf-8", errors="replace") as fh:
            for number, line in enumerate(fh, start=1):
                for url in urls_in(line):
                    entries.append(("%s:%d" % (args.from_file, number), url))

    token = os.environ.get("GITHUB_TOKEN")
    held_fingerprint, live = None, False
    if token:
        held_fingerprint = fingerprint(token)
        session = requests.Session()
        session.headers.update({
            "Authorization": "Bearer " + token,
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
            "User-Agent": UA,
        })
        # In the header, never in the URL. GET /rate_limit spends no quota.
        status, _ = get(session, "/rate_limit")
        live = status == 200
        log.info("credential in this process: %s, %s", held_fingerprint,
                 "still live" if live else "not accepted (status %s)" % status)
    else:
        log.info("set GITHUB_TOKEN to also learn whether a credential found in "
                 "a URL is the one you are holding, and whether it still works")

    log.info("scanned %d url(s)", len(entries))
    findings = audit(entries)
    for item in findings:
        log.warning("%s carries %s (%s, %d chars) in ?%s= ; scrubbed: %s",
                    item["where"], item["fingerprint"], item["shape"],
                    item["length"], item["param"], item["redacted"])

    state, detail = verdict(findings, live, held_fingerprint)
    log.info("%s: %s", state, detail)

    if findings:
        log.info("repair: move the credential into Authorization: Bearer TOKEN "
                 "on every request, including inside any client wrapper that "
                 "appends parameters for you.")
        log.info("repair: revoke and re-mint before scrubbing. Revocation takes "
                 "seconds and log retention takes days.")
        log.info("note: this script cannot enumerate where a URL has already "
                 "been written, and it will not reproduce the leak to measure "
                 "it. The 60-versus-5000 reading belongs to the anonymous-tier "
                 "check, with the header removed rather than the token moved.")

    print(json.dumps({"scanned": len(entries), "findings": findings,
                      "state": state}, indent=2))
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
github-token-in-url.mjs
/**
 * Find GitHub credentials sitting in URLs, without ever printing one.
 *
 * Read only, and one request: GET /rate_limit with the credential in an
 * Authorization header. That call spends no quota and answers the only
 * question the API can answer here.
 *
 * This never issues a request with a credential in the query string, not even
 * to reproduce the documented anonymous-tier reading, and it never emits a
 * credential value. Findings carry a shape, a length and a truncated digest.
 */
import { createHash, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const API = 'https://api.github.com';
const UA = 'github-token-in-url/1.0';

/** Documented token prefixes. The shape is not itself a secret. */
export const PREFIXES = [
  ['github_pat_', 'fine-grained-pat'],
  ['ghp_', 'classic-pat'],
  ['gho_', 'oauth-token'],
  ['ghu_', 'app-user-token'],
  ['ghs_', 'app-installation-token'],
  ['ghr_', 'refresh-token'],
];
const LEGACY_HEX = /^[0-9a-f]{40}$/;

/** Parameter names that carry a credential whatever the value looks like. */
export const SUSPECT_NAMES = new Set(['access_token', 'token', 'oauth_token',
  'api_key', 'apikey', 'client_secret', 'private_token', 'auth', 'password',
  'secret']);

/**
 * Parameter names whose values are legitimately forty hex characters. Without
 * these, every commit SHA in a URL is reported as a legacy token.
 */
export const GIT_OBJECT_NAMES = new Set(['sha', 'commit_sha', 'head_sha',
  'base_sha', 'tree_sha', 'oid', 'ref', 'base', 'head']);

const URL_PATTERN = /https?:\/\/[^\s<>]+/g;

/** Punctuation a log line puts after a URL. */
const TRAILING = /["'>),.;]+$/;

export const REDACTED = 'REDACTED';

/** Name the kind of credential a string looks like. Pure. */
export function shapeOf(value) {
  const text = String(value ?? '');
  for (const [prefix, name] of PREFIXES) {
    if (text.startsWith(prefix)) return name;
  }
  if (LEGACY_HEX.test(text)) return 'legacy-hex40';
  return text.length >= 16 ? 'opaque' : 'short';
}

/** A twelve-character digest, for correlating two sightings. Pure. */
export function fingerprint(value) {
  const digest = createHash('sha256').update(String(value ?? ''), 'utf8').digest('hex');
  return `sha256:${digest.slice(0, 12)}`;
}

/** Do two fingerprints describe the same value? Pure. */
export function sameCredential(left, right) {
  if (!left || !right) return false;
  const a = Buffer.from(String(left));
  const b = Buffer.from(String(right));
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

/** Every URL in a blob of log or configuration. Pure. */
export function urlsIn(text) {
  return (String(text ?? '').match(URL_PATTERN) ?? [])
    .map((u) => u.replace(TRAILING, ''));
}

function paramsOf(url) {
  try {
    return new URL(String(url ?? '')).searchParams;
  } catch {
    return null;
  }
}

/**
 * Would this query parameter be reported as carrying a credential? Pure.
 * The single place that decision is made, so the reporter and the redactor
 * cannot drift apart about what counts as a secret.
 */
export function isCredential(name, value) {
  const lowered = String(name ?? '').toLowerCase();
  if (SUSPECT_NAMES.has(lowered)) return true;
  if (GIT_OBJECT_NAMES.has(lowered)) return false;
  const shape = shapeOf(value);
  return shape !== 'short' && shape !== 'opaque';
}

/** Credential-bearing query parameters in one URL. Pure. */
export function credentialParams(url) {
  const params = paramsOf(url);
  if (!params) return [];
  const out = [];
  for (const [name, value] of params.entries()) {
    if (!isCredential(name, value)) continue;
    out.push({
      param: name,
      shape: shapeOf(value),
      length: value.length,
      fingerprint: fingerprint(value),
      ignored_by_github: name.toLowerCase() === 'access_token',
    });
  }
  return out;
}

/** The same URL with every credential-bearing value replaced. Pure. */
export function redact(url) {
  let parsed;
  try {
    parsed = new URL(String(url ?? ''));
  } catch {
    return REDACTED;
  }
  if (!parsed.search) return String(url ?? '');
  const next = new URLSearchParams();
  for (const [name, value] of parsed.searchParams.entries()) {
    next.append(name, isCredential(name, value) ? REDACTED : value);
  }
  parsed.search = next.toString();
  return parsed.toString();
}

/** Findings across many labelled URLs, with the redacted form attached. Pure. */
export function audit(entries) {
  const findings = [];
  for (const [label, url] of entries ?? []) {
    for (const hit of credentialParams(url)) {
      findings.push({ ...hit, where: label, redacted: redact(url) });
    }
  }
  return findings;
}

/** Turn the findings into a decision about revocation. Pure. */
export function verdict(findings, live, heldFingerprint) {
  if (!findings || !findings.length) {
    return ['no-credential-in-url',
      'no query parameter carried a credential-shaped value.'];
  }

  const matched = findings.filter((f) => sameCredential(f.fingerprint, heldFingerprint));
  const ignored = findings.filter((f) => f.ignored_by_github);
  const distinct = new Set(findings.map((f) => f.fingerprint)).size;

  const tail = ignored.length
    ? ` ${ignored.length} of them use access_token, which GitHub ignores ` +
      'outright, so those requests went out anonymous rather than authenticated.'
    : '';

  if (matched.length && live) {
    return ['live-credential-in-url',
      `${findings.length} occurrence(s) of ${distinct} distinct credential(s) ` +
      'in URLs, and one of them is the credential this process is holding, ' +
      'which still authenticates. Revoke it; relocating it to a header does ' +
      `not unwrite the log lines.${tail}`];
  }
  if (matched.length) {
    return ['dead-credential-in-url',
      `${findings.length} occurrence(s) in URLs match the credential this ` +
      'process holds, and that credential no longer authenticates. The ' +
      `exposure is historical, but the habit that created it is not.${tail}`];
  }
  return ['credential-in-url',
    `${findings.length} occurrence(s) of ${distinct} distinct ` +
    'credential-shaped value(s) in URLs. None match the credential this ' +
    'process holds, so their liveness cannot be judged from here; treat them ' +
    `as live until somebody proves otherwise.${tail}`];
}

async function get(token, path) {
  const url = path.startsWith('/') ? API + path : path;
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  return { status: res.status };
}

async function main() {
  const args = process.argv.slice(2);
  const fileIndex = args.indexOf('--from-file');
  const fromFile = fileIndex === -1 ? null : args[fileIndex + 1];
  const urlArgs = args.filter((a, i) =>
    a !== '--from-file' && i !== fileIndex + 1);

  const entries = urlArgs.map((u, i) => [`argv[${i + 1}]`, u]);
  if (fromFile) {
    const lines = (await readFile(fromFile, 'utf8')).split('\n');
    for (let i = 0; i < lines.length; i += 1) {
      for (const url of urlsIn(lines[i])) entries.push([`${fromFile}:${i + 1}`, url]);
    }
  }

  const token = process.env.GITHUB_TOKEN;
  let heldFingerprint = null;
  let live = false;
  if (token) {
    heldFingerprint = fingerprint(token);
    // In the header, never in the URL. GET /rate_limit spends no quota.
    const rate = await get(token, '/rate_limit');
    live = rate.status === 200;
    console.log(`credential in this process: ${heldFingerprint}, ` +
      (live ? 'still live' : `not accepted (status ${rate.status})`));
  } else {
    console.log('set GITHUB_TOKEN to also learn whether a credential found in ' +
      'a URL is the one you are holding, and whether it still works');
  }

  console.log(`scanned ${entries.length} url(s)`);
  const findings = audit(entries);
  for (const item of findings) {
    console.warn(`${item.where} carries ${item.fingerprint} (${item.shape}, ` +
      `${item.length} chars) in ?${item.param}= ; scrubbed: ${item.redacted}`);
  }

  const [state, detail] = verdict(findings, live, heldFingerprint);
  console.log(`${state}: ${detail}`);

  if (findings.length) {
    console.log('repair: move the credential into Authorization: Bearer TOKEN ' +
      'on every request, including inside any client wrapper that appends ' +
      'parameters for you.');
    console.log('repair: revoke and re-mint before scrubbing. Revocation takes ' +
      'seconds and log retention takes days.');
    console.log('note: this script cannot enumerate where a URL has already ' +
      'been written, and it will not reproduce the leak to measure it.');
  }

  console.log(JSON.stringify({ scanned: entries.length, findings, state }, null, 2));
  process.exitCode = findings.length ? 1 : 0;
}

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

Add a test

The load-bearing test in this file is a negative one: take an obviously fake token, put it through every function that produces output, and assert the value appears in none of them. The rest cover the cases that decide whether a report is useful — a credential hiding under an innocuous parameter name, two sightings of the same value correlating by digest, and a redacted URL that keeps the parts you need to find the request again.

test_github_token_in_url.py
import json

from github_token_in_url import (
    audit, credential_params, fingerprint, is_credential, redact,
    same_credential, shape_of, urls_in, verdict)

FAKE = "ghp_FAKE0000000001"
OTHER = "ghs_FAKE0000000002"
BASE = "https://api.github.com/repos/acme/api/issues"


def test_documented_prefixes_are_named():
    assert shape_of(FAKE) == "classic-pat"
    assert shape_of(OTHER) == "app-installation-token"
    assert shape_of("github_pat_FAKE01") == "fine-grained-pat"
    assert shape_of("a" * 40) == "legacy-hex40"


def test_a_short_value_is_not_treated_as_a_credential():
    assert shape_of("30") == "short"
    assert shape_of("") == "short"


def test_a_fingerprint_is_short_stable_and_not_the_value():
    fp = fingerprint(FAKE)
    assert fp.startswith("sha256:")
    assert len(fp) == len("sha256:") + 12
    assert fp == fingerprint(FAKE)
    assert FAKE not in fp


def test_two_sightings_of_one_value_correlate():
    assert same_credential(fingerprint(FAKE), fingerprint(FAKE)) is True
    assert same_credential(fingerprint(FAKE), fingerprint(OTHER)) is False
    assert same_credential(None, fingerprint(FAKE)) is False


def test_the_named_parameter_is_found():
    hits = credential_params("%s?access_token=%s&state=open" % (BASE, FAKE))
    assert len(hits) == 1
    assert hits[0]["param"] == "access_token"
    assert hits[0]["shape"] == "classic-pat"
    assert hits[0]["ignored_by_github"] is True


def test_a_credential_hiding_under_a_harmless_name_is_still_found():
    hits = credential_params("%s?key=%s" % (BASE, FAKE))
    assert len(hits) == 1
    assert hits[0]["param"] == "key"
    assert hits[0]["ignored_by_github"] is False


def test_a_commit_sha_is_not_reported_as_a_legacy_token():
    assert shape_of("a" * 40) == "legacy-hex40"
    assert is_credential("sha", "a" * 40) is False
    assert credential_params("%s?sha=%s" % (BASE, "a" * 40)) == []


def test_a_credential_name_beats_the_git_object_exemption():
    assert is_credential("access_token", "a" * 40) is True


def test_ordinary_parameters_are_left_alone():
    assert credential_params("%s?state=open&per_page=100" % BASE) == []


def test_a_url_with_no_query_is_not_a_finding():
    assert credential_params(BASE) == []


def test_redaction_keeps_the_request_and_drops_the_secret():
    scrubbed = redact("%s?access_token=%s&state=open" % (BASE, FAKE))
    assert FAKE not in scrubbed
    assert "REDACTED" in scrubbed
    assert "state=open" in scrubbed
    assert "/repos/acme/api/issues" in scrubbed


def test_urls_are_pulled_out_of_a_log_line():
    line = '10.0.0.1 - - "GET %s?access_token=%s HTTP/1.1" 200' % (BASE, FAKE)
    found = urls_in(line)
    assert len(found) == 1
    assert found[0].startswith("https://api.github.com/")


def test_nothing_the_script_prints_contains_the_credential():
    entries = [("access.log:12", "%s?access_token=%s" % (BASE, FAKE))]
    findings = audit(entries)
    state, detail = verdict(findings, True, fingerprint(FAKE))
    printed = json.dumps(findings) + state + detail
    assert FAKE not in printed
    assert "sha256:" in printed


def test_a_live_match_demands_revocation():
    findings = audit([("access.log:12", "%s?access_token=%s" % (BASE, FAKE))])
    state, detail = verdict(findings, True, fingerprint(FAKE))
    assert state == "live-credential-in-url"
    assert "Revoke it" in detail
    assert "anonymous" in detail


def test_a_match_on_a_dead_credential_is_historical():
    findings = audit([("access.log:12", "%s?access_token=%s" % (BASE, FAKE))])
    state, detail = verdict(findings, False, fingerprint(FAKE))
    assert state == "dead-credential-in-url"
    assert "historical" in detail


def test_an_unknown_credential_is_assumed_live():
    findings = audit([("access.log:12", "%s?access_token=%s" % (BASE, OTHER))])
    state, detail = verdict(findings, True, fingerprint(FAKE))
    assert state == "credential-in-url"
    assert "treat them as live" in detail


def test_distinct_credentials_are_counted_separately():
    findings = audit([("a", "%s?access_token=%s" % (BASE, FAKE)),
                      ("b", "%s?access_token=%s" % (BASE, FAKE)),
                      ("c", "%s?access_token=%s" % (BASE, OTHER))])
    _, detail = verdict(findings, False, None)
    assert "3 occurrence(s)" in detail
    assert "2 distinct" in detail


def test_a_clean_scan_says_so():
    assert verdict([], True, fingerprint(FAKE))[0] == "no-credential-in-url"
github-token-in-url.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  audit, credentialParams, fingerprint, isCredential, redact, sameCredential,
  shapeOf, urlsIn, verdict,
} from './github-token-in-url.mjs';

const FAKE = 'ghp_FAKE0000000001';
const OTHER = 'ghs_FAKE0000000002';
const BASE = 'https://api.github.com/repos/acme/api/issues';

test('documented prefixes are named', () => {
  assert.equal(shapeOf(FAKE), 'classic-pat');
  assert.equal(shapeOf(OTHER), 'app-installation-token');
  assert.equal(shapeOf('github_pat_FAKE01'), 'fine-grained-pat');
  assert.equal(shapeOf('a'.repeat(40)), 'legacy-hex40');
});

test('a short value is not treated as a credential', () => {
  assert.equal(shapeOf('30'), 'short');
  assert.equal(shapeOf(''), 'short');
});

test('a fingerprint is short, stable and not the value', () => {
  const fp = fingerprint(FAKE);
  assert.ok(fp.startsWith('sha256:'));
  assert.equal(fp.length, 'sha256:'.length + 12);
  assert.equal(fp, fingerprint(FAKE));
  assert.ok(!fp.includes(FAKE));
});

test('two sightings of one value correlate', () => {
  assert.equal(sameCredential(fingerprint(FAKE), fingerprint(FAKE)), true);
  assert.equal(sameCredential(fingerprint(FAKE), fingerprint(OTHER)), false);
  assert.equal(sameCredential(null, fingerprint(FAKE)), false);
});

test('the named parameter is found', () => {
  const hits = credentialParams(`${BASE}?access_token=${FAKE}&state=open`);
  assert.equal(hits.length, 1);
  assert.equal(hits[0].param, 'access_token');
  assert.equal(hits[0].shape, 'classic-pat');
  assert.equal(hits[0].ignored_by_github, true);
});

test('a credential hiding under a harmless name is still found', () => {
  const hits = credentialParams(`${BASE}?key=${FAKE}`);
  assert.equal(hits.length, 1);
  assert.equal(hits[0].param, 'key');
  assert.equal(hits[0].ignored_by_github, false);
});

test('a commit sha is not reported as a legacy token', () => {
  assert.equal(shapeOf('a'.repeat(40)), 'legacy-hex40');
  assert.equal(isCredential('sha', 'a'.repeat(40)), false);
  assert.deepEqual(credentialParams(`${BASE}?sha=${'a'.repeat(40)}`), []);
});

test('a credential name beats the git object exemption', () => {
  assert.equal(isCredential('access_token', 'a'.repeat(40)), true);
});

test('ordinary parameters are left alone', () => {
  assert.deepEqual(credentialParams(`${BASE}?state=open&per_page=100`), []);
});

test('a url with no query is not a finding', () => {
  assert.deepEqual(credentialParams(BASE), []);
});

test('redaction keeps the request and drops the secret', () => {
  const scrubbed = redact(`${BASE}?access_token=${FAKE}&state=open`);
  assert.ok(!scrubbed.includes(FAKE));
  assert.match(scrubbed, /REDACTED/);
  assert.match(scrubbed, /state=open/);
  assert.match(scrubbed, /\/repos\/acme\/api\/issues/);
});

test('urls are pulled out of a log line', () => {
  const line = `10.0.0.1 - - "GET ${BASE}?access_token=${FAKE} HTTP/1.1" 200`;
  const found = urlsIn(line);
  assert.equal(found.length, 1);
  assert.ok(found[0].startsWith('https://api.github.com/'));
});

test('nothing the script prints contains the credential', () => {
  const findings = audit([['access.log:12', `${BASE}?access_token=${FAKE}`]]);
  const [state, detail] = verdict(findings, true, fingerprint(FAKE));
  const printed = JSON.stringify(findings) + state + detail;
  assert.ok(!printed.includes(FAKE));
  assert.match(printed, /sha256:/);
});

test('a live match demands revocation', () => {
  const findings = audit([['access.log:12', `${BASE}?access_token=${FAKE}`]]);
  const [state, detail] = verdict(findings, true, fingerprint(FAKE));
  assert.equal(state, 'live-credential-in-url');
  assert.match(detail, /Revoke it/);
  assert.match(detail, /anonymous/);
});

test('a match on a dead credential is historical', () => {
  const findings = audit([['access.log:12', `${BASE}?access_token=${FAKE}`]]);
  const [state, detail] = verdict(findings, false, fingerprint(FAKE));
  assert.equal(state, 'dead-credential-in-url');
  assert.match(detail, /historical/);
});

test('an unknown credential is assumed live', () => {
  const findings = audit([['access.log:12', `${BASE}?access_token=${OTHER}`]]);
  const [state, detail] = verdict(findings, true, fingerprint(FAKE));
  assert.equal(state, 'credential-in-url');
  assert.match(detail, /treat them as live/);
});

test('distinct credentials are counted separately', () => {
  const findings = audit([
    ['a', `${BASE}?access_token=${FAKE}`],
    ['b', `${BASE}?access_token=${FAKE}`],
    ['c', `${BASE}?access_token=${OTHER}`],
  ]);
  const [, detail] = verdict(findings, false, null);
  assert.match(detail, /3 occurrence\(s\)/);
  assert.match(detail, /2 distinct/);
});

test('a clean scan says so', () => {
  assert.equal(verdict([], true, fingerprint(FAKE))[0], 'no-credential-in-url');
});

FAQ

The token in the URL is correct. Why is the request unauthenticated?

Because GitHub no longer reads it from there. Support for the access_token query parameter was removed, and a parameter that is not read is not an error: the request is simply processed as anonymous. That is why the first symptom is usually a rate-limit surprise rather than a 401, since anonymous callers get 60 requests an hour per IP address rather than 5,000 per token. Endpoints that need a caller then start answering Requires authentication, which is the loud half of the same cause.

Do I really have to revoke the token, or is moving it to a header enough?

Revoke it. Moving the credential to a header stops new copies being written and does nothing at all about the ones already in your proxy's access log, GitHub's request path, the CI job's stored output, the trace store and whatever browser or terminal history saw it. You cannot audit all of those and you certainly cannot delete all of them, so the only thing that closes the exposure is making the value useless. Revoke first, re-mint, redeploy, and treat scrubbing as cleanup rather than as the fix.

Why does the script refuse to send the token in a URL to prove the problem?

Because the proof costs a fresh copy of the secret in every log between your process and GitHub, and it tells you something you can determine without it. The documented reading is that a query-string credential reports a core limit of 60 instead of 5,000, and that reading is worth having, but the way to get it safely is to look at the tier with no credential at all, which is the anonymous-tier note. Reproducing a leak in order to measure it is not a diagnostic anyone should ship.

Is a truncated SHA-256 of the token safe to put in a ticket?

Twelve hex characters of a SHA-256 is not reversible to the input, and its purpose is narrow: to say that the value in this log line is the same value as the one in that log line, or the same one this process is holding. It is a correlation handle rather than a redaction of a low-entropy field, which would be a different question. The alternative in practice is people pasting the last few characters of the real token, which is genuinely worse.

What about a token in the path, or in a webhook URL, rather than in the query string?

Everything in this note applies with more force. A path is logged exactly like a query string, and a credential embedded in a webhook delivery URL is additionally sitting in the webhook's configuration where anybody with repository admin can read it back. The script audits query parameters because that is the form GitHub used to accept and the form that still shows up in old code, but the rule underneath it is simply that a URL is a public string and a header 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.