Diagnostic GitHub API
a classic token nobody used for a year is deleted for you
The restore drill is on a Tuesday morning and the token that has been sitting in the vault since the runbook was written answers 401. It has not expired, because it was created with no expiry. Nobody revoked it. It is not in the account's token list at all, because GitHub removed it for going a year without being used, and the reason it went a year without being used is that it is a token for emergencies.
GitHub deletes classic personal access tokens that have not been used for a year. There is no warning header, no notification and nothing in the API to read, because the only credential class this applies to is also the class that never carried an expiry to count down.
So the clock cannot come from GitHub, and it does not have to. It comes from your side: for each stored credential, how often does something actually exercise it? Subtract that from the one-year window and you have the margin. A daily job has 364 days of it. A quarterly job has 274. An annual disaster-recovery drill has none, and the token backing it is going to be gone the next time it matters.
The repair is a probe that doubles as a keep-alive. GET /rate_limit costs no quota, needs no scope and counts as a use, so a scheduled call per credential both proves the credential still works and stops the reaper reaching it. The script below runs that probe across a manifest of credentials and prints the schedule it would take to keep each one alive.
The problem in plain words
What makes this one nasty is the correlation. The credentials most likely to be reaped are the credentials reserved for the moments you can least afford to be without them: break-glass access, a restore path, an annual compliance export, a failover script that has never once had to run. Frequency of use is inversely related to importance here, and the policy is indexed on frequency.
It also fails silently in a way that expiry does not. An expiring token has a date on it, so somebody at some point wrote that date on a calendar or a dashboard. A classic token created with no expiration looks permanent, is documented as permanent, and is treated as permanent by every runbook that mentions it. The dormancy rule is the one clock nobody set, and it starts running the moment the token stops being used.
Then the recovery is worse than a rotation. A token that expired can be reissued from the same place with the same settings and the person who created it can usually be found. A token that was reaped is gone from the list entirely, which means there is often no record of what scopes it had, which account it belonged to, or who created it — and you are reconstructing all of that during the incident the token existed for.
Why it happens
The API cannot tell you when a credential was last used. Not for a token you hold, and certainly not for the others on the account: a read-only credential cannot enumerate an account's tokens at all. So there is no endpoint to poll for this and no header to watch. The measurement has to be made on your side, out of something you already know, which is the schedule of the jobs that use each credential.
Only one credential class is reapable. Fine-grained personal access tokens carry an expiry by default and are governed by that date. Installation access tokens live for an hour. OAuth user tokens die when somebody revokes the authorization, which is a decision rather than a clock. The dormancy rule is about classic personal access tokens created without an expiry, and the script's first job is to say whether the credential in front of it is even in that class.
The absent header is the tell. A credential that reports github-authentication-token-expiration has a date, and the countdown on that date is a different note with a different repair. A credential that reports nothing is the one this page is about. That is a clean split: the tokens whose death you can see coming in a header are exactly the tokens that dormancy cannot take, and the tokens dormancy can take announce nothing at all.
The probe is free and it is also the fix. GET /rate_limit does not consume quota, requires no scope, and is a use of the credential. So a monthly scheduled call per credential is both the monitoring and the mitigation, which is unusual enough to be worth stating plainly: there is no separate remediation to build.
The probe has to run on its own schedule. This is the part that gets implemented wrong. A liveness check inside the annual job runs annually, which is the interval that caused the problem. The probe belongs in something that runs on its own cadence and touches every stored credential, including the ones no running job currently uses.
The fix, as a flow
Nothing is failing when you run this, and the API cannot tell you when a credential was last used, so the clock has to come from your own side: how often each job exercises each credential. The script probes every credential once against a call that costs no quota, which both answers the question and resets the clock it is measuring.
How to fix it
Write down every credential and the cadence that exercises it
One line each: the environment variable it lives in, what uses it, and how often that runs. This is the input the API cannot supply and the reason this check is a five-minute conversation rather than a monitoring integration. Include the credentials nothing currently uses, which are the ones at risk.
Probe each one with a call that costs nothing
GET /rate_limit is free of quota and needs no scope, so it works for the narrowest credential you own. A 200 says the credential is alive today. A 401 says it is already gone, and for this class of token there is nothing to un-revoke.
Read the expiry header to decide whether dormancy even applies
If github-authentication-token-expiration comes back, the credential has a date and a different note owns it. If it does not, and the value looks like a classic token, it is in the reapable class and the margin calculation is the next step.
Subtract the cadence from the window and look at the margin
A year minus the exercise interval. Zero or less means the reaper wins by definition. Under about two months means one skipped run, one paused pipeline or one quiet quarter loses the race. Anything else is covered by the job itself and needs nothing.
Schedule the probe separately from the job it protects
Monthly is enough, and it is twelve free requests a year per credential. Put it somewhere that runs on its own cadence, give it the whole manifest, and alert on the 401 rather than on the margin — the margin is what you fix in advance, the 401 is what you never want to see.
How to check it worked
Run it against the manifest. The state to look for is not a failure, it is the annual credential with no margin, which is reported while everything is still working.
python3 github_token_dormancy.py --manifest credentials.json
# GITHUB_TOKEN nightly sync covered margin 364d
# EXPORT_TOKEN quarterly export covered margin 274d
# DR_RESTORE_TOKEN annual restore drill reap-race-lost margin 0d
# repair: probe this credential every 30 days. GET /rate_limit costs no
# quota, needs no scope, and counts as a use, so the probe is the fix.
# crontab: 0 6 1 * *
The full code
One free request per credential, and the arithmetic that matters happens without any. The pure half is a class test that decides whether a credential is even reapable, a margin calculation against the one-year window, a state machine over the probe result and the cadence, and a recommendation that turns a cadence into a crontab line. The manifest is the only input the API cannot provide, which is why it is a file you write rather than something the script discovers.
"""Say which stored credentials will be reaped for disuse before they are needed.
Read only, and free: one GET /rate_limit per credential, which consumes no
quota and requires no scope. That call is also the mitigation, because it
counts as a use, so running this on a schedule is both the monitoring and the
repair. Nothing here mints, rotates or revokes anything.
GitHub removes classic personal access tokens that have gone a year without
being used. That class of credential carries no expiry and emits no header, so
the clock cannot come from the API. It comes from the manifest: how often each
credential is actually exercised by the job that owns it.
Manifest format, a JSON list:
[{"env": "GITHUB_TOKEN", "label": "nightly sync",
"exercised_every_days": 1},
{"env": "DR_RESTORE_TOKEN", "label": "annual restore drill",
"exercised_every_days": 365}]
"""
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_token_dormancy")
API = "https://api.github.com"
UA = "github-token-dormancy/1.0 (+https://example.com/contact)"
# The published dormancy window, and the margin below which one skipped run
# loses the race. Both are arguments rather than constants at the call sites
# so a stricter shop can tighten them without editing the logic.
WINDOW_DAYS = 365
TIGHT_DAYS = 60
def token_class(value):
"""Name the credential class from its prefix. Pure. Never returns the value."""
if value is None or not str(value).strip():
return "absent"
text = str(value).strip()
if text.startswith("github_pat_"):
return "fine-grained"
if text.startswith("ghp_"):
return "classic"
if text.startswith("ghs_"):
return "installation"
if text.startswith("gho_") or text.startswith("ghu_"):
return "oauth"
if len(text) == 40 and all(c in "0123456789abcdef" for c in text.lower()):
return "classic"
return "unknown"
def reap_exposure(kind, expires_header):
"""Decide whether a credential is even in the class the reaper can take. Pure.
The header check comes first and wins. A credential that reports an expiry
has a date, and a date is a different clock with a different note attached
to it; dormancy cannot reach it.
"""
if expires_header:
return ("not-reapable-expiring",
"this credential reports an expiry, so it dies on a date "
"rather than from disuse. The countdown on that date is a "
"different check.")
if kind == "classic":
return ("reapable",
"a classic token with no expiry reported. This is the only "
"class GitHub removes for disuse, and it emits no header to "
"warn you.")
if kind == "fine-grained":
return ("not-reapable-fine-grained",
"fine-grained tokens carry an expiry by default, so they are "
"governed by a date even when this request did not show one.")
if kind == "installation":
return ("not-reapable-short-lived",
"an installation access token lives about an hour. It is "
"minted per run and dormancy is meaningless for it.")
if kind == "oauth":
return ("not-reapable-oauth",
"an OAuth user token dies when somebody revokes the "
"authorization, which is a decision rather than a clock.")
return ("unknown-class",
"the credential does not match a known prefix, so its class "
"cannot be named from its text. Treat it as reapable until "
"somebody confirms otherwise.")
def margin_days(interval_days, window_days=WINDOW_DAYS):
"""Days of headroom between one use and the reaping window. Pure.
None when the cadence is unknown, because guessing here produces a
confident wrong answer about a credential nobody is watching.
"""
try:
interval = float(interval_days)
except (TypeError, ValueError):
return None
return window_days - interval
def dormancy_state(probe_status, exposure, interval_days,
window_days=WINDOW_DAYS, tight_days=TIGHT_DAYS):
"""Turn a probe result and an exercise cadence into a finding. Pure."""
if probe_status == 401:
return ("already-gone",
"the credential is refused. For this class there is nothing "
"to un-revoke: mint a replacement and record what it is for.")
if probe_status is None or probe_status >= 400:
return ("unreachable",
"the probe did not come back cleanly, so nothing can be said "
"about the credential yet. Fix the probe first.")
if exposure != "reapable" and exposure != "unknown-class":
return ("not-reapable",
"alive, and not in the class that gets reaped for disuse.")
margin = margin_days(interval_days, window_days)
if margin is None:
return ("cadence-unknown",
"alive, and reapable, but the manifest does not say how often "
"anything exercises it. That is the number this check needs.")
if margin <= 0:
return ("reap-race-lost",
"alive today, and nothing exercises it inside the window. "
"This credential will be removed before it is next needed.")
if margin < tight_days:
return ("reap-race-tight",
"alive, with less headroom than one skipped run. A paused "
"pipeline or a quiet quarter loses this race.")
return ("covered",
"alive, and exercised often enough that the job itself keeps the "
"credential from going dormant.")
def probe_interval(interval_days, window_days=WINDOW_DAYS):
"""Recommend a keep-alive cadence in days. Pure.
At most thirty days, because a monthly probe leaves eleven months of
margin and costs twelve free requests a year. Never slower than the job it
protects, because a probe slower than the job adds nothing to it.
"""
try:
interval = float(interval_days)
except (TypeError, ValueError):
interval = float(window_days)
return int(max(1, min(30.0, interval)))
def keepalive_cron(days):
"""A crontab line for a keep-alive at the given cadence. Pure."""
if days <= 1:
return "0 6 * * *"
if days <= 7:
return "0 6 * * 1"
return "0 6 1 * *"
def probe(token):
"""One free GET. Returns (status, expiry header or None)."""
response = requests.get(API + "/rate_limit", timeout=30, headers={
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
headers = {k.lower(): v for k, v in response.headers.items()}
return response.status_code, headers.get("github-authentication-token-expiration")
def load_manifest(path):
"""Read the manifest, or build a one-entry one from the environment."""
if path:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
return [{"env": "GITHUB_TOKEN", "label": "the credential in GITHUB_TOKEN",
"exercised_every_days": None}]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--manifest",
help="JSON list of {env, label, exercised_every_days}")
ap.add_argument("--window-days", type=int, default=WINDOW_DAYS,
help="the dormancy window to measure against")
ap.add_argument("--tight-days", type=int, default=TIGHT_DAYS,
help="margin below which one skipped run loses the race")
args = ap.parse_args()
entries = load_manifest(args.manifest)
findings = []
for entry in entries:
name = entry.get("env", "")
label = entry.get("label", name)
cadence = entry.get("exercised_every_days")
token = os.environ.get(name)
if not token:
log.warning("%-20s %-24s no value in the environment", name, label)
findings.append({"env": name, "state": "not-set"})
continue
kind = token_class(token)
status, expires = probe(token)
exposure, exposure_detail = reap_exposure(kind, expires)
state, detail = dormancy_state(status, exposure, cadence,
args.window_days, args.tight_days)
margin = margin_days(cadence, args.window_days)
log.info("%-20s %-24s %-16s margin %s", name, label, state,
"%dd" % margin if margin is not None else "unknown")
log.info(" class %s: %s", kind, exposure_detail)
log.info(" %s", detail)
if state in ("reap-race-lost", "reap-race-tight", "cadence-unknown"):
every = probe_interval(cadence, args.window_days)
log.info(" repair: probe this credential every %d days. "
"GET /rate_limit costs no quota, needs no scope, and "
"counts as a use, so the probe is the fix.", every)
log.info(" crontab: %s", keepalive_cron(every))
log.info(" repair: schedule it separately from the job that "
"owns the credential. A check inside an annual job runs "
"annually, which is the interval that caused this.")
if state == "already-gone":
log.info(" repair: mint a replacement, then record its purpose "
"and owner somewhere the next drill will find them.")
findings.append({"env": name, "label": label, "class": kind,
"exposure": exposure, "status": status,
"margin_days": margin, "state": state})
print(json.dumps(findings, indent=2))
bad = {"reap-race-lost", "reap-race-tight", "already-gone"}
return 1 if any(f.get("state") in bad for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say which stored credentials will be reaped for disuse before they are needed.
*
* Read only, and free: one GET /rate_limit per credential, which consumes no
* quota and requires no scope. That call is also the mitigation, because it
* counts as a use of the credential.
*
* GitHub removes classic personal access tokens that have gone a year without
* being used. That class carries no expiry and emits no header, so the clock
* comes from the manifest rather than from the API.
*/
import { readFileSync } from 'node:fs';
const API = 'https://api.github.com';
const UA = 'github-token-dormancy/1.0 (+https://example.com/contact)';
export const WINDOW_DAYS = 365;
export const TIGHT_DAYS = 60;
/** Name the credential class from its prefix. Pure. Never returns the value. */
export function tokenClass(value) {
if (value === null || value === undefined || !String(value).trim()) return 'absent';
const text = String(value).trim();
if (text.startsWith('github_pat_')) return 'fine-grained';
if (text.startsWith('ghp_')) return 'classic';
if (text.startsWith('ghs_')) return 'installation';
if (text.startsWith('gho_') || text.startsWith('ghu_')) return 'oauth';
if (text.length === 40 && /^[0-9a-f]+$/.test(text.toLowerCase())) return 'classic';
return 'unknown';
}
/** Decide whether a credential is in the class the reaper can take. Pure. */
export function reapExposure(kind, expiresHeader) {
if (expiresHeader) {
return ['not-reapable-expiring',
'this credential reports an expiry, so it dies on a date rather than ' +
'from disuse. The countdown on that date is a different check.'];
}
if (kind === 'classic') {
return ['reapable',
'a classic token with no expiry reported. This is the only class ' +
'GitHub removes for disuse, and it emits no header to warn you.'];
}
if (kind === 'fine-grained') {
return ['not-reapable-fine-grained',
'fine-grained tokens carry an expiry by default, so they are governed ' +
'by a date even when this request did not show one.'];
}
if (kind === 'installation') {
return ['not-reapable-short-lived',
'an installation access token lives about an hour. It is minted per ' +
'run and dormancy is meaningless for it.'];
}
if (kind === 'oauth') {
return ['not-reapable-oauth',
'an OAuth user token dies when somebody revokes the authorization, ' +
'which is a decision rather than a clock.'];
}
return ['unknown-class',
'the credential does not match a known prefix, so its class cannot be ' +
'named from its text. Treat it as reapable until somebody confirms ' +
'otherwise.'];
}
/** Days of headroom between one use and the reaping window. Pure. */
export function marginDays(intervalDays, windowDays = WINDOW_DAYS) {
const interval = Number(intervalDays);
if (intervalDays === null || intervalDays === undefined
|| intervalDays === '' || Number.isNaN(interval)) return null;
return windowDays - interval;
}
/** Turn a probe result and an exercise cadence into a finding. Pure. */
export function dormancyState(probeStatus, exposure, intervalDays,
windowDays = WINDOW_DAYS, tightDays = TIGHT_DAYS) {
if (probeStatus === 401) {
return ['already-gone',
'the credential is refused. For this class there is nothing to ' +
'un-revoke: mint a replacement and record what it is for.'];
}
if (probeStatus === null || probeStatus === undefined || probeStatus >= 400) {
return ['unreachable',
'the probe did not come back cleanly, so nothing can be said about ' +
'the credential yet. Fix the probe first.'];
}
if (exposure !== 'reapable' && exposure !== 'unknown-class') {
return ['not-reapable',
'alive, and not in the class that gets reaped for disuse.'];
}
const margin = marginDays(intervalDays, windowDays);
if (margin === null) {
return ['cadence-unknown',
'alive, and reapable, but the manifest does not say how often anything ' +
'exercises it. That is the number this check needs.'];
}
if (margin <= 0) {
return ['reap-race-lost',
'alive today, and nothing exercises it inside the window. This ' +
'credential will be removed before it is next needed.'];
}
if (margin < tightDays) {
return ['reap-race-tight',
'alive, with less headroom than one skipped run. A paused pipeline or ' +
'a quiet quarter loses this race.'];
}
return ['covered',
'alive, and exercised often enough that the job itself keeps the ' +
'credential from going dormant.'];
}
/** Recommend a keep-alive cadence in days. Pure. */
export function probeInterval(intervalDays, windowDays = WINDOW_DAYS) {
let interval = Number(intervalDays);
if (intervalDays === null || intervalDays === undefined
|| intervalDays === '' || Number.isNaN(interval)) interval = windowDays;
return Math.trunc(Math.max(1, Math.min(30, interval)));
}
/** A crontab line for a keep-alive at the given cadence. Pure. */
export function keepaliveCron(days) {
if (days <= 1) return '0 6 * * *';
if (days <= 7) return '0 6 * * 1';
return '0 6 1 * *';
}
async function probe(token) {
const res = await fetch(`${API}/rate_limit`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
return [res.status, res.headers.get('github-authentication-token-expiration')];
}
function arg(name, fallback) {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : process.argv[i + 1];
}
async function main() {
const manifestPath = arg('--manifest', null);
const entries = manifestPath
? JSON.parse(readFileSync(manifestPath, 'utf8'))
: [{ env: 'GITHUB_TOKEN', label: 'the credential in GITHUB_TOKEN', exercised_every_days: null }];
const findings = [];
for (const entry of entries) {
const name = entry.env ?? '';
const label = entry.label ?? name;
const cadence = entry.exercised_every_days ?? null;
const token = process.env[name];
if (!token) {
console.warn(`${name.padEnd(20)} ${String(label).padEnd(24)} no value in the environment`);
findings.push({ env: name, state: 'not-set' });
continue;
}
const kind = tokenClass(token);
const [status, expires] = await probe(token);
const [exposure, exposureDetail] = reapExposure(kind, expires);
const [state, detail] = dormancyState(status, exposure, cadence);
const margin = marginDays(cadence);
console.log(`${name.padEnd(20)} ${String(label).padEnd(24)} ${state.padEnd(16)} `
+ `margin ${margin === null ? 'unknown' : `${margin}d`}`);
console.log(` class ${kind}: ${exposureDetail}`);
console.log(` ${detail}`);
if (state === 'reap-race-lost' || state === 'reap-race-tight' || state === 'cadence-unknown') {
const every = probeInterval(cadence);
console.log(` repair: probe this credential every ${every} days. `
+ 'GET /rate_limit costs no quota, needs no scope, and counts as a '
+ 'use, so the probe is the fix.');
console.log(` crontab: ${keepaliveCron(every)}`);
console.log(' repair: schedule it separately from the job that owns '
+ 'the credential. A check inside an annual job runs annually, which '
+ 'is the interval that caused this.');
}
if (state === 'already-gone') {
console.log(' repair: mint a replacement, then record its purpose and '
+ 'owner somewhere the next drill will find them.');
}
findings.push({ env: name, label, class: kind, exposure, status, marginDays: margin, state });
}
console.log(JSON.stringify(findings, null, 2));
const bad = ['reap-race-lost', 'reap-race-tight', 'already-gone'];
process.exitCode = findings.some((f) => bad.includes(f.state)) ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not fire live requests and set an exit code the suite then inherits.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The arithmetic is the part worth pinning, because the whole finding is one subtraction and the interesting cases are the boundaries: exactly a year, one day inside it, an unknown cadence that must not be guessed at, and a credential whose expiry header takes it out of scope entirely. None of it needs a token, and the fixtures that stand in for one are four characters long.
from github_token_dormancy import (
dormancy_state, keepalive_cron, margin_days, probe_interval, reap_exposure,
token_class,
)
def test_each_prefix_names_its_class():
assert token_class("ghp_fake") == "classic"
assert token_class("github_pat_fk") == "fine-grained"
assert token_class("ghs_fake") == "installation"
assert token_class("gho_fake") == "oauth"
assert token_class("ghu_fake") == "oauth"
assert token_class("0" * 40) == "classic"
assert token_class("something") == "unknown"
assert token_class(None) == "absent"
def test_an_expiry_header_takes_a_credential_out_of_scope():
state, detail = reap_exposure("classic", "2026-09-30 12:00:00 UTC")
assert state == "not-reapable-expiring"
assert "different check" in detail
def test_a_classic_token_with_no_expiry_is_the_reapable_class():
assert reap_exposure("classic", None)[0] == "reapable"
def test_the_other_classes_die_of_other_causes():
assert reap_exposure("fine-grained", None)[0] == "not-reapable-fine-grained"
assert reap_exposure("installation", None)[0] == "not-reapable-short-lived"
assert reap_exposure("oauth", None)[0] == "not-reapable-oauth"
assert reap_exposure("unknown", None)[0] == "unknown-class"
def test_margin_is_the_window_minus_the_cadence():
assert margin_days(1) == 364
assert margin_days(90) == 275
assert margin_days(365) == 0
def test_an_unknown_cadence_is_not_guessed_at():
assert margin_days(None) is None
assert margin_days("sometimes") is None
def test_an_annual_job_has_lost_the_race_before_it_starts():
state, detail = dormancy_state(200, "reapable", 365)
assert state == "reap-race-lost"
assert "before it is next needed" in detail
def test_one_day_inside_the_window_is_still_tight():
assert dormancy_state(200, "reapable", 364)[0] == "reap-race-tight"
def test_a_frequent_job_keeps_its_own_credential_alive():
assert dormancy_state(200, "reapable", 1)[0] == "covered"
assert dormancy_state(200, "reapable", 90)[0] == "covered"
def test_a_reaped_credential_is_already_gone():
state, detail = dormancy_state(401, "reapable", 1)
assert state == "already-gone"
assert "nothing to un-revoke" in detail
def test_a_credential_out_of_scope_is_reported_as_such():
assert dormancy_state(200, "not-reapable-expiring", 365)[0] == "not-reapable"
def test_an_unknown_class_is_treated_as_reapable():
assert dormancy_state(200, "unknown-class", 365)[0] == "reap-race-lost"
def test_a_missing_cadence_is_its_own_state():
state, detail = dormancy_state(200, "reapable", None)
assert state == "cadence-unknown"
assert "how often" in detail
def test_a_broken_probe_says_nothing_about_the_credential():
assert dormancy_state(500, "reapable", 1)[0] == "unreachable"
assert dormancy_state(None, "reapable", 1)[0] == "unreachable"
def test_the_probe_is_never_slower_than_a_month():
assert probe_interval(365) == 30
assert probe_interval(90) == 30
assert probe_interval(7) == 7
assert probe_interval(1) == 1
def test_an_unknown_cadence_gets_the_monthly_probe():
assert probe_interval(None) == 30
def test_the_cadence_becomes_a_crontab_line():
assert keepalive_cron(1) == "0 6 * * *"
assert keepalive_cron(7) == "0 6 * * 1"
assert keepalive_cron(30) == "0 6 1 * *"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
dormancyState, keepaliveCron, marginDays, probeInterval, reapExposure,
tokenClass,
} from './github-token-dormancy.mjs';
test('each prefix names its class', () => {
assert.equal(tokenClass('ghp_fake'), 'classic');
assert.equal(tokenClass('github_pat_fk'), 'fine-grained');
assert.equal(tokenClass('ghs_fake'), 'installation');
assert.equal(tokenClass('gho_fake'), 'oauth');
assert.equal(tokenClass('ghu_fake'), 'oauth');
assert.equal(tokenClass('0'.repeat(40)), 'classic');
assert.equal(tokenClass('something'), 'unknown');
assert.equal(tokenClass(null), 'absent');
});
test('an expiry header takes a credential out of scope', () => {
const [state, detail] = reapExposure('classic', '2026-09-30 12:00:00 UTC');
assert.equal(state, 'not-reapable-expiring');
assert.ok(detail.includes('different check'));
});
test('a classic token with no expiry is the reapable class', () => {
assert.equal(reapExposure('classic', null)[0], 'reapable');
});
test('the other classes die of other causes', () => {
assert.equal(reapExposure('fine-grained', null)[0], 'not-reapable-fine-grained');
assert.equal(reapExposure('installation', null)[0], 'not-reapable-short-lived');
assert.equal(reapExposure('oauth', null)[0], 'not-reapable-oauth');
assert.equal(reapExposure('unknown', null)[0], 'unknown-class');
});
test('margin is the window minus the cadence', () => {
assert.equal(marginDays(1), 364);
assert.equal(marginDays(90), 275);
assert.equal(marginDays(365), 0);
});
test('an unknown cadence is not guessed at', () => {
assert.equal(marginDays(null), null);
assert.equal(marginDays('sometimes'), null);
});
test('an annual job has lost the race before it starts', () => {
const [state, detail] = dormancyState(200, 'reapable', 365);
assert.equal(state, 'reap-race-lost');
assert.ok(detail.includes('before it is next needed'));
});
test('one day inside the window is still tight', () => {
assert.equal(dormancyState(200, 'reapable', 364)[0], 'reap-race-tight');
});
test('a frequent job keeps its own credential alive', () => {
assert.equal(dormancyState(200, 'reapable', 1)[0], 'covered');
assert.equal(dormancyState(200, 'reapable', 90)[0], 'covered');
});
test('a reaped credential is already gone', () => {
const [state, detail] = dormancyState(401, 'reapable', 1);
assert.equal(state, 'already-gone');
assert.ok(detail.includes('nothing to un-revoke'));
});
test('a credential out of scope is reported as such', () => {
assert.equal(dormancyState(200, 'not-reapable-expiring', 365)[0], 'not-reapable');
});
test('an unknown class is treated as reapable', () => {
assert.equal(dormancyState(200, 'unknown-class', 365)[0], 'reap-race-lost');
});
test('a missing cadence is its own state', () => {
const [state, detail] = dormancyState(200, 'reapable', null);
assert.equal(state, 'cadence-unknown');
assert.ok(detail.includes('how often'));
});
test('a broken probe says nothing about the credential', () => {
assert.equal(dormancyState(500, 'reapable', 1)[0], 'unreachable');
assert.equal(dormancyState(null, 'reapable', 1)[0], 'unreachable');
});
test('the probe is never slower than a month', () => {
assert.equal(probeInterval(365), 30);
assert.equal(probeInterval(90), 30);
assert.equal(probeInterval(7), 7);
assert.equal(probeInterval(1), 1);
});
test('an unknown cadence gets the monthly probe', () => {
assert.equal(probeInterval(null), 30);
});
test('the cadence becomes a crontab line', () => {
assert.equal(keepaliveCron(1), '0 6 * * *');
assert.equal(keepaliveCron(7), '0 6 * * 1');
assert.equal(keepaliveCron(30), '0 6 1 * *');
});
FAQ
Which tokens does GitHub actually remove for inactivity?
Classic personal access tokens that have gone a year without being used. Fine-grained personal access tokens are governed by the expiry date they are created with, installation access tokens for GitHub Apps live about an hour and are minted per run, and OAuth user tokens end when somebody revokes the authorization rather than when they go quiet. So the exposure is narrow, but it lands on exactly the credential type people reach for when they want something that never expires.
Can I ask the API when a token was last used?
No. A read-only credential can read its own scopes and its own account, and it cannot enumerate the tokens on that account or see when any of them were last exercised. That is why this check takes a manifest: the cadence at which each credential is used is something you know and the API does not. The one thing the API will tell you is whether the credential works right now, which is the probe.
Does GET /rate_limit really count as using the token?
It is an authenticated request made with that credential, which is what the dormancy rule counts. It is also the cheapest request available: it does not consume any of your hourly quota, it needs no scope at all, and it works for the narrowest credential you own. That combination is unusual enough to build on, because it means the monitoring and the mitigation are the same call and there is no separate remediation to write.
Should I just add an expiry to everything instead?
Mostly yes, and it changes which problem you have rather than removing it. A credential with an expiry announces itself through a response header, which makes the countdown visible and automatable, and it converts an unbounded leak into a bounded one. It also means somebody has to rotate on a schedule. The genuinely bad option is the one this note is about: a credential with no expiry, no header and no scheduled use, which looks permanent right up to the moment it is not.
How do I tell a reaped token from an expired or revoked one?
Usually you cannot, from the response. All three answer 401 Bad credentials, because GitHub does not explain refusals. The discriminator is the record you kept rather than anything that came back: a token with an expiry date died on a date you could have predicted, a token somebody revoked has an audit entry, and a token that is simply absent from the account's list with neither of those explanations went dormant. Which is why the useful work is the margin calculation, done a year before the 401.
Related field notes
- The token expires in days and nobody is watching
- A classic PAT passed its expiry date
- A token that can delete repositories
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.
- Managing your personal access tokens — GitHub Docs
- Rate limit — GitHub REST API
- Authenticating to the REST API — GitHub Docs
- Keeping your API credentials secure — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.