Diagnostic GitHub API
the token expires in days and nothing is watching the clock
Nothing is broken. That is the entire point of this note: it is the one that runs before the outage rather than after it. Somewhere in your environment is a credential with six days left on it, and the only thing standing between you and a 09:14 Tuesday is that nobody has read a header GitHub has been sending on every single response for months.
Every authenticated REST response for a credential that has an expiry carries github-authentication-token-expiration, with the exact timestamp. One free request reads it. Fine-grained personal access tokens must have an expiry, capped at 366 days unless the organization permits longer, and organizations can enforce something much shorter, so for those the question is never whether but when.
The honest half of the check is what happens when the header is not there, and it is why this cannot be a one-liner. An absent header means either the credential never expires — which is a bigger risk than one that does, not a smaller one — or that its class does not report an expiry. Those are different findings with different repairs, and a script that prints "no expiry" for both is worse than no script. The one below names which, alerts at 30, 14 and 3 days, and refuses to raise an alarm about a GitHub App installation token whose one-hour life is the desired state.
The problem in plain words
Expiry has no warning signal in normal operation. There is no 429, no deprecation notice, no degraded mode, no email to the service account nobody reads. The credential works perfectly at 09:13 and is refused at 09:14, and the failure is total across every endpoint at once. As failure modes go it is maximally abrupt and minimally informative.
The information is also asymmetric in the worst direction. The expiry is readable only while the credential still works, and becomes unreadable at exactly the moment you want it. So the check has to be running before the incident or it can never run at all — there is no post-mortem version of it.
And the naive version of the check is actively misleading. A script that reports "no expiry found, all good" gives a clean bill of health to a credential that never expires, which is the one you should be most worried about, and to a credential whose class simply does not report one, about which it has learned nothing. Two very different silences, printed identically.
Why it happens
The header is on ordinary responses, not on a special endpoint. There is no "describe my token" API. GitHub attaches github-authentication-token-expiration to authenticated REST responses for credentials that carry an expiry, which means any free call — GET /rate_limit is the obvious one, since it consumes no quota — will tell you.
You can only ask about the credential you are holding. A read-only token cannot enumerate the tokens on an account, so this is a per-credential check, not an inventory of everything your organization has issued. The way to cover a fleet is to name every environment variable that holds one and check them all in the same run, which is what the script does.
Absence of the header is ambiguous and must be reported as such. Classic PATs can be minted with no expiry at all; some credential classes do not surface one. The script separates "the request succeeded and there was no header" from "the request failed so nothing could be read", because only the first of those is a finding about the credential.
An expiry under two hours is almost always good news. A GitHub App installation token lives about an hour and is minted automatically. Alarming on it would train everyone to ignore the alert. The script labels it as short-lived and says plainly that it cannot distinguish it from a PAT in its final two hours, which is the rare case where you would want to know.
Thresholds beat a single alarm. Thirty days is when someone can plan a rotation, fourteen is when it goes on a sprint, three is when it is an interrupt. One alert at zero is not monitoring, it is an incident with extra steps.
The fix, as a flow
One free request per credential and the answer is in a header that has been arriving for months. The interesting branch is the one where there is no header at all, because a credential that never expires and a class that does not report a date are different findings and a careless script prints them the same.
How to fix it
List every environment variable that holds a GitHub credential
Not just the obvious one. Most integrations have accumulated a second token for a different account, a control credential from the last incident, and one in a CI secret nobody has touched in a year. The check costs one free request each, so the marginal cost of including a credential is nothing and the cost of omitting it is the outage.
Make one free authenticated request per credential and read the header
GET /rate_limit is authenticated and consumes no quota, which makes it the right probe to run on a schedule. Read github-authentication-token-expiration off the response case-insensitively, because header case is not something to rely on.
Classify the credentials that report no expiry, rather than passing them
A successful request with no expiry header means the credential does not expire, or does not say. Report that as its own state and treat a never-expiring credential as a finding to act on: an unrotated permanent token is a larger long-term risk than one with a date attached.
Bucket by 30, 14 and 3 days and sort by urgency
Sorting by soonest is not enough on its own, because an unreadable credential should outrank one with ninety days left. Order by state first and by remaining time second, so the top line of the report is always the thing to do next.
Move the unattended ones to a GitHub App and stop keeping the calendar
For automation with no human owner, an App installation removes the whole category: tokens are minted on demand, live about an hour, and never appear in anyone's diary. Rotation stops being a task. Keep the expiry check running afterwards anyway — it will show short-lived and prove the migration actually happened.
How to check it worked
Run it on a schedule and read the first line. The report is ordered so the top row is the credential to deal with, whether that is because it is closest to expiry or because it could not be read at all.
python3 github_token_expiry_watch.py GITHUB_TOKEN GITHUB_CI_TOKEN GITHUB_BOT_TOKEN
# GITHUB_CI_TOKEN critical 2.4 day(s) read from the header
# GITHUB_TOKEN notice 28.9 day(s) read from the header
# GITHUB_BOT_TOKEN no-expiry-reported - succeeded and carried no
# expiry header
# critical: GITHUB_CI_TOKEN expires in 2.4 day(s). Alert at 30, 14 and 3 days
# rather than at zero.
The full code
One free GET per credential and nothing else on the wire. The credentials are read from named environment variables and never printed, compared or fingerprinted: this script does not care what class a token is, only what its response headers say about its remaining life. The parsing is the fiddly part — the timestamp arrives in more than one shape and a timezone that has to be honoured — so it is a pure function, as are the bucketing, the classification of a missing header, and the ordering.
"""Read how long each GitHub credential has left, before it costs you an outage.
Read only. One GET /rate_limit per credential, which is authenticated and
consumes no quota from any bucket, so this is safe to run on a schedule.
GitHub attaches github-authentication-token-expiration to authenticated REST
responses for credentials that carry an expiry. That header is the only place
the date is readable, and it is readable only while the credential still works.
"""
import argparse
import calendar
import json
import logging
import os
import re
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_token_expiry_watch")
API = "https://api.github.com"
UA = "github-token-expiry-watch/1.0"
HEADER = "github-authentication-token-expiration"
DAY = 86400
# Under two hours on a working credential is a minted GitHub App installation
# token, which is the desired end state rather than an emergency. It is also a
# personal access token in its final two hours, and the header cannot tell them
# apart, so the report says both.
SHORT_LIVED_S = 2 * 3600
# Notice, warning, critical. One alarm at zero is not monitoring.
DEFAULT_THRESHOLDS = (30, 14, 3)
_ISO_T = re.compile(r"^(\d{4}-\d{2}-\d{2})T")
_STAMP = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}):(\d{2})(?::(\d{2}))?)?$")
_OFFSET = re.compile(r"([+-]\d{2}:?\d{2})$")
def parse_expiry(value):
"""Epoch seconds from the expiry header, or None. Pure.
The documented shape is "2026-09-30 12:00:00 UTC", but an ISO timestamp with
a Z or a numeric offset turns up too. Anything that does not parse returns
None rather than a plausible wrong date, because a wrong expiry is worse
than no expiry.
"""
if not isinstance(value, str):
return None
# Only the ISO separator, never any other T: the documented shape ends in
# "UTC", and a blanket replace turns that into "U C".
text = _ISO_T.sub(r"\1 ", value.strip())
if not text:
return None
offset = "+0000"
upper = text.upper()
if upper.endswith(" UTC") or upper.endswith(" GMT"):
text = text[:-4].strip()
elif upper.endswith("Z"):
text = text[:-1].strip()
else:
found = _OFFSET.search(text)
if found:
offset = found.group(1).replace(":", "")
text = text[:found.start()].strip()
stamp = _STAMP.match(text)
if not stamp:
return None
year, month, day, hour, minute, second = stamp.groups()
sign = 1 if offset[0] == "-" else -1
shift = sign * (int(offset[1:3]) * 3600 + int(offset[3:5]) * 60)
base = calendar.timegm((int(year), int(month), int(day), int(hour or 0),
int(minute or 0), int(second or 0), 0, 0, 0))
return base + shift
def header_value(headers, name=HEADER):
"""Case-insensitive header lookup. Pure."""
for key, value in (headers or {}).items():
if str(key).lower() == name:
return value
return None
def seconds_left(expiry, now):
"""Seconds between now and the expiry; None when either is unreadable. Pure."""
try:
return int(expiry) - int(now)
except (TypeError, ValueError):
return None
def bucket(remaining, thresholds=DEFAULT_THRESHOLDS):
"""Name the urgency of a remaining lifetime. Pure."""
if remaining is None:
return "unknown"
if remaining <= 0:
return "expired"
if remaining < SHORT_LIVED_S:
return "short-lived"
notice, warning, critical = thresholds
days = remaining / DAY
if days <= critical:
return "critical"
if days <= warning:
return "warning"
if days <= notice:
return "notice"
return "ok"
def reading(name, status, headers, now, thresholds=DEFAULT_THRESHOLDS):
"""One credential's expiry reading, including why there might not be one. Pure.
The states that matter are the two silences. A request that succeeded and
carried no expiry header is a finding about the credential. A request that
failed is a finding about nothing at all, and printing them the same way is
how a monitoring script gives a permanent token a clean bill of health.
"""
try:
status = int(status)
except (TypeError, ValueError):
status = 0
if status == 401:
return {"name": name, "state": "rejected", "seconds_left": None,
"why": "the credential was refused, so its expiry is no longer "
"a forecast"}
if not 200 <= status < 300:
return {"name": name, "state": "unreadable", "seconds_left": None,
"why": "the probe returned %d, so nothing can be read from its "
"headers" % status}
raw = header_value(headers)
if raw is None:
return {"name": name, "state": "no-expiry-reported", "seconds_left": None,
"why": "the request succeeded and carried no expiry header, "
"which means either the credential never expires or its "
"class does not report one. The header cannot tell those "
"apart"}
expiry = parse_expiry(raw)
if expiry is None:
return {"name": name, "state": "unreadable-header", "seconds_left": None,
"why": "the expiry header was present but did not parse: %r" % raw}
remaining = seconds_left(expiry, now)
return {"name": name, "state": bucket(remaining, thresholds),
"seconds_left": remaining, "expires_at": expiry,
"why": "read from the %s response header" % HEADER}
# Urgency first. An unreadable credential outranks one with ninety days left,
# because you have learned nothing about it and that is worse than good news.
ORDER = {"expired": 0, "critical": 1, "warning": 2, "rejected": 3,
"unreadable-header": 4, "unreadable": 5, "no-expiry-reported": 6,
"notice": 7, "short-lived": 8, "ok": 9, "unknown": 10}
def schedule(rows):
"""Order the readings by urgency, then by soonest. Pure."""
def key(row):
remaining = row.get("seconds_left")
return (ORDER.get(row.get("state"), 99),
remaining if isinstance(remaining, int) else 1 << 30,
str(row.get("name")))
return sorted(rows or [], key=key)
def verdict(ordered):
"""The one line to act on. Pure."""
if not ordered:
return ("nothing-checked",
"no credentials were named, so nothing was checked.")
top = ordered[0]
state = top["state"]
name = top["name"]
remaining = top.get("seconds_left")
if state == "expired":
return ("expired",
"%s has already passed its expiry. It will be answering 401 Bad "
"credentials, identically to a credential that was revoked." % name)
if state in ("critical", "warning", "notice"):
return (state,
"%s expires in %.1f day(s). Alert at 30, 14 and 3 days rather "
"than at zero." % (name, remaining / DAY))
if state == "short-lived":
return ("short-lived",
"%s expires in %d minute(s), which is what a freshly minted "
"GitHub App installation token looks like and is a non-event. "
"It is also what a personal access token in its final two hours "
"looks like, and the header does not distinguish them."
% (name, remaining // 60))
if state == "rejected":
return ("rejected",
"%s was refused, so there is no expiry left to forecast. Whether "
"it expired or was revoked is not observable from here." % name)
if state in ("unreadable", "unreadable-header"):
return ("unreadable", "%s could not be read: %s" % (name, top.get("why")))
if state == "no-expiry-reported":
return ("no-expiry-reported",
"%s reported no expiry. Either it never expires, which is a "
"larger standing risk than one that does, or its class does not "
"surface a date. Find out which before calling it healthy." % name)
return ("ok", "the soonest expiry is %s at %.1f day(s)."
% (name, (remaining or 0) / DAY))
def probe(name, token):
"""One free authenticated GET. Returns (status, headers)."""
try:
response = requests.get(
API + "/rate_limit",
headers={"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA},
timeout=30)
except requests.RequestException as exc:
log.error("%s: request failed: %s", name, exc)
return 0, {}
return response.status_code, dict(response.headers)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("env", nargs="*", default=["GITHUB_TOKEN"],
help="environment variable names holding credentials")
parser.add_argument("--notice", type=int, default=DEFAULT_THRESHOLDS[0])
parser.add_argument("--warning", type=int, default=DEFAULT_THRESHOLDS[1])
parser.add_argument("--critical", type=int, default=DEFAULT_THRESHOLDS[2])
args = parser.parse_args()
thresholds = (args.notice, args.warning, args.critical)
now = int(time.time())
rows = []
for name in (args.env or ["GITHUB_TOKEN"]):
token = os.environ.get(name)
if not token:
rows.append({"name": name, "state": "unreadable", "seconds_left": None,
"why": "the environment variable is not set"})
continue
status, headers = probe(name, token)
rows.append(reading(name, status, headers, now, thresholds))
ordered = schedule(rows)
for row in ordered:
remaining = row.get("seconds_left")
left = "-" if remaining is None else "%.1f day(s)" % (remaining / DAY)
log.info("%-20s %-20s %12s %s", row["name"], row["state"], left,
row.get("why", ""))
state, detail = verdict(ordered)
log.info("%s: %s", state, detail)
if state in ("critical", "warning", "expired"):
log.info("repair: rotate now, and record the new expiry in the same "
"place the secret is stored so the next person sees it.")
if state in ("critical", "warning", "notice", "expired", "no-expiry-reported"):
log.info("repair: for automation with no human owner, authenticate as a "
"GitHub App installation. Its tokens are minted on demand, live "
"about an hour, and never need a diary entry.")
print(json.dumps({"state": state, "readings": ordered}, indent=2))
return 1 if state not in ("ok", "short-lived", "nothing-checked") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Read how long each GitHub credential has left, before it costs you an outage.
*
* Read only. One GET /rate_limit per credential, which is authenticated and
* consumes no quota, so this is safe to run on a schedule.
*
* github-authentication-token-expiration is the only place the date is
* readable, and it is readable only while the credential still works.
*/
const API = 'https://api.github.com';
const UA = 'github-token-expiry-watch/1.0';
export const HEADER = 'github-authentication-token-expiration';
export const DAY = 86400;
// Under two hours on a working credential is a minted App installation token,
// which is the desired end state. It is also a PAT in its final two hours, and
// the header cannot tell them apart.
export const SHORT_LIVED_S = 2 * 3600;
// Notice, warning, critical. One alarm at zero is not monitoring.
export const DEFAULT_THRESHOLDS = [30, 14, 3];
const STAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
const OFFSET = /([+-]\d{2}:?\d{2})$/;
/**
* Epoch seconds from the expiry header, or null. Pure.
* Anything that does not parse returns null rather than a plausible wrong date.
*/
export function parseExpiry(value) {
if (typeof value !== 'string') return null;
// Only the ISO separator, never any other T: the documented shape ends in
// 'UTC', and a blanket replace turns that into 'U C'.
let text = value.trim().replace(/^(\d{4}-\d{2}-\d{2})T/, '$1 ');
if (!text) return null;
let offset = '+0000';
const upper = text.toUpperCase();
if (upper.endsWith(' UTC') || upper.endsWith(' GMT')) {
text = text.slice(0, -4).trim();
} else if (upper.endsWith('Z')) {
text = text.slice(0, -1).trim();
} else {
const found = text.match(OFFSET);
if (found) {
offset = found[1].replace(':', '');
text = text.slice(0, found.index).trim();
}
}
const stamp = text.match(STAMP);
if (!stamp) return null;
const [, year, month, day, hour, minute, second] = stamp;
const sign = offset[0] === '-' ? 1 : -1;
const shift = sign * (Number(offset.slice(1, 3)) * 3600 + Number(offset.slice(3, 5)) * 60);
const base = Date.UTC(Number(year), Number(month) - 1, Number(day),
Number(hour ?? 0), Number(minute ?? 0), Number(second ?? 0)) / 1000;
return base + shift;
}
/** Case-insensitive header lookup. Pure. */
export function headerValue(headers, name = HEADER) {
for (const [key, value] of Object.entries(headers ?? {})) {
if (String(key).toLowerCase() === name) return value;
}
return null;
}
/** Seconds between now and the expiry; null when either is unreadable. Pure. */
export function secondsLeft(expiry, now) {
const e = Number.parseInt(expiry, 10);
const n = Number.parseInt(now, 10);
if (!Number.isFinite(e) || !Number.isFinite(n)) return null;
return e - n;
}
/** Name the urgency of a remaining lifetime. Pure. */
export function bucket(remaining, thresholds = DEFAULT_THRESHOLDS) {
if (remaining === null || remaining === undefined) return 'unknown';
if (remaining <= 0) return 'expired';
if (remaining < SHORT_LIVED_S) return 'short-lived';
const [notice, warning, critical] = thresholds;
const days = remaining / DAY;
if (days <= critical) return 'critical';
if (days <= warning) return 'warning';
if (days <= notice) return 'notice';
return 'ok';
}
/** One credential's expiry reading, including why there might not be one. Pure. */
export function reading(name, status, headers, now, thresholds = DEFAULT_THRESHOLDS) {
const code = Number.parseInt(status, 10);
const value = Number.isFinite(code) ? code : 0;
if (value === 401) {
return { name, state: 'rejected', seconds_left: null,
why: 'the credential was refused, so its expiry is no longer a forecast' };
}
if (!(value >= 200 && value < 300)) {
return { name, state: 'unreadable', seconds_left: null,
why: `the probe returned ${value}, so nothing can be read from its headers` };
}
const raw = headerValue(headers);
if (raw === null || raw === undefined) {
return { name, state: 'no-expiry-reported', seconds_left: null,
why: 'the request succeeded and carried no expiry header, which means ' +
'either the credential never expires or its class does not report one. ' +
'The header cannot tell those apart' };
}
const expiry = parseExpiry(raw);
if (expiry === null) {
return { name, state: 'unreadable-header', seconds_left: null,
why: `the expiry header was present but did not parse: '${raw}'` };
}
const remaining = secondsLeft(expiry, now);
return { name, state: bucket(remaining, thresholds), seconds_left: remaining,
expires_at: expiry, why: `read from the ${HEADER} response header` };
}
// Urgency first. An unreadable credential outranks one with ninety days left.
export const ORDER = {
expired: 0, critical: 1, warning: 2, rejected: 3, 'unreadable-header': 4,
unreadable: 5, 'no-expiry-reported': 6, notice: 7, 'short-lived': 8, ok: 9,
unknown: 10,
};
/** Order the readings by urgency, then by soonest. Pure. */
export function schedule(rows) {
const rank = (row) => [
ORDER[row.state] ?? 99,
Number.isInteger(row.seconds_left) ? row.seconds_left : 2 ** 30,
String(row.name),
];
return [...(rows ?? [])].sort((a, b) => {
const x = rank(a);
const y = rank(b);
for (let i = 0; i < x.length; i += 1) {
if (x[i] < y[i]) return -1;
if (x[i] > y[i]) return 1;
}
return 0;
});
}
/** The one line to act on. Pure. */
export function verdict(ordered) {
if (!ordered || !ordered.length) {
return ['nothing-checked', 'no credentials were named, so nothing was checked.'];
}
const top = ordered[0];
const { state, name } = top;
const remaining = top.seconds_left;
if (state === 'expired') {
return ['expired',
`${name} has already passed its expiry. It will be answering 401 Bad ` +
'credentials, identically to a credential that was revoked.'];
}
if (['critical', 'warning', 'notice'].includes(state)) {
return [state,
`${name} expires in ${(remaining / DAY).toFixed(1)} day(s). Alert at 30, ` +
'14 and 3 days rather than at zero.'];
}
if (state === 'short-lived') {
return ['short-lived',
`${name} expires in ${Math.floor(remaining / 60)} minute(s), which is what ` +
'a freshly minted GitHub App installation token looks like and is a ' +
'non-event. It is also what a personal access token in its final two hours ' +
'looks like, and the header does not distinguish them.'];
}
if (state === 'rejected') {
return ['rejected',
`${name} was refused, so there is no expiry left to forecast. Whether it ` +
'expired or was revoked is not observable from here.'];
}
if (state === 'unreadable' || state === 'unreadable-header') {
return ['unreadable', `${name} could not be read: ${top.why}`];
}
if (state === 'no-expiry-reported') {
return ['no-expiry-reported',
`${name} reported no expiry. Either it never expires, which is a larger ` +
'standing risk than one that does, or its class does not surface a date. ' +
'Find out which before calling it healthy.'];
}
return ['ok',
`the soonest expiry is ${name} at ${((remaining ?? 0) / DAY).toFixed(1)} day(s).`];
}
async function probe(name, token) {
try {
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,
},
});
const headers = {};
for (const [k, v] of res.headers.entries()) headers[k.toLowerCase()] = v;
return { status: res.status, headers };
} catch (err) {
console.error(`${name}: request failed: ${err.message}`);
return { status: 0, headers: {} };
}
}
async function main() {
const names = process.argv.slice(2);
const wanted = names.length ? names : ['GITHUB_TOKEN'];
const now = Math.floor(Date.now() / 1000);
const rows = [];
for (const name of wanted) {
const token = process.env[name];
if (!token) {
rows.push({ name, state: 'unreadable', seconds_left: null,
why: 'the environment variable is not set' });
continue;
}
const { status, headers } = await probe(name, token);
rows.push(reading(name, status, headers, now));
}
const ordered = schedule(rows);
for (const row of ordered) {
const left = row.seconds_left === null ? '-' : `${(row.seconds_left / DAY).toFixed(1)} day(s)`;
console.log(`${row.name.padEnd(20)} ${row.state.padEnd(20)} ${left.padStart(12)} ${row.why ?? ''}`);
}
const [state, detail] = verdict(ordered);
console.log(`${state}: ${detail}`);
if (['critical', 'warning', 'expired'].includes(state)) {
console.log('repair: rotate now, and record the new expiry in the same place ' +
'the secret is stored so the next person sees it.');
}
if (['critical', 'warning', 'notice', 'expired', 'no-expiry-reported'].includes(state)) {
console.log('repair: for automation with no human owner, authenticate as a ' +
'GitHub App installation; its tokens live about an hour and need no diary entry.');
}
console.log(JSON.stringify({ state, readings: ordered }, null, 2));
process.exitCode = ['ok', 'short-lived', 'nothing-checked'].includes(state) ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err); process.exitCode = 2; });
}
Add a test
Three things decide whether this report is trustworthy, and none of them needs a network. The timestamp has to parse in every shape GitHub sends it in and return nothing at all for a shape it does not recognise, because a plausible wrong date is worse than a blank. A successful request with no header and a failed request must land in different states. And the ordering has to put an unreadable credential above a healthy one, because good news you cannot verify is not good news.
from github_token_expiry_watch import (
bucket, header_value, parse_expiry, reading, schedule, seconds_left, verdict,
)
NOON = 1790769600 # 2026-09-30 12:00:00 UTC
def test_the_documented_shape_parses_to_the_right_instant():
assert parse_expiry("2026-09-30 12:00:00 UTC") == NOON
def test_the_iso_shapes_parse_to_the_same_instant():
assert parse_expiry("2026-09-30T12:00:00Z") == NOON
assert parse_expiry("2026-09-30T12:00:00+00:00") == NOON
def test_a_numeric_offset_is_honoured_rather_than_ignored():
assert parse_expiry("2026-09-30 07:00:00 -0500") == NOON
assert parse_expiry("2026-09-30 14:00:00 +02:00") == NOON
def test_a_bare_date_is_read_as_midnight_utc():
assert parse_expiry("2026-09-30") == NOON - 12 * 3600
def test_a_shape_that_is_not_recognised_returns_nothing_at_all():
assert parse_expiry("soon") is None
assert parse_expiry("30/09/2026") is None
assert parse_expiry("") is None
assert parse_expiry(None) is None
def test_the_header_is_found_whatever_its_case():
assert header_value({"Github-Authentication-Token-Expiration": "x"}) == "x"
assert header_value({"github-authentication-token-expiration": "y"}) == "y"
assert header_value({"etag": "z"}) is None
assert header_value(None) is None
def test_remaining_time_is_a_number_or_nothing():
assert seconds_left(NOON, NOON - 60) == 60
assert seconds_left(None, NOON) is None
assert seconds_left(NOON, "later") is None
def test_the_thresholds_bucket_as_advertised():
assert bucket(None) == "unknown"
assert bucket(0) == "expired"
assert bucket(-1) == "expired"
assert bucket(3600) == "short-lived"
assert bucket(2 * 86400) == "critical"
assert bucket(10 * 86400) == "warning"
assert bucket(20 * 86400) == "notice"
assert bucket(90 * 86400) == "ok"
def test_custom_thresholds_are_respected():
assert bucket(20 * 86400, thresholds=(60, 30, 21)) == "critical"
def test_a_successful_request_with_no_header_is_its_own_state():
row = reading("GITHUB_TOKEN", 200, {"etag": "abc"}, NOON)
assert row["state"] == "no-expiry-reported"
assert "never expires or its class does not report one" in row["why"]
def test_a_failed_request_is_not_the_same_silence():
assert reading("GITHUB_TOKEN", 500, {}, NOON)["state"] == "unreadable"
assert reading("GITHUB_TOKEN", 0, {}, NOON)["state"] == "unreadable"
def test_a_refused_credential_has_no_forecast_left():
assert reading("GITHUB_TOKEN", 401, {}, NOON)["state"] == "rejected"
def test_an_unparseable_header_is_reported_rather_than_guessed():
row = reading("GITHUB_TOKEN", 200,
{"github-authentication-token-expiration": "next tuesday"}, NOON)
assert row["state"] == "unreadable-header"
assert "did not parse" in row["why"]
def test_a_live_reading_carries_the_remaining_seconds():
headers = {"github-authentication-token-expiration": "2026-09-30 12:00:00 UTC"}
row = reading("GITHUB_TOKEN", 200, headers, NOON - 2 * 86400)
assert row["state"] == "critical"
assert row["seconds_left"] == 2 * 86400
assert row["expires_at"] == NOON
def test_an_unreadable_credential_outranks_a_healthy_one():
rows = [{"name": "b", "state": "ok", "seconds_left": 90 * 86400},
{"name": "a", "state": "unreadable", "seconds_left": None},
{"name": "c", "state": "critical", "seconds_left": 2 * 86400}]
assert [row["name"] for row in schedule(rows)] == ["c", "a", "b"]
def test_the_soonest_wins_inside_one_state():
rows = [{"name": "later", "state": "warning", "seconds_left": 12 * 86400},
{"name": "sooner", "state": "warning", "seconds_left": 8 * 86400}]
assert schedule(rows)[0]["name"] == "sooner"
def test_the_verdict_is_the_top_row():
ordered = schedule([{"name": "GITHUB_CI_TOKEN", "state": "critical",
"seconds_left": 2 * 86400}])
state, detail = verdict(ordered)
assert state == "critical"
assert "2.0 day(s)" in detail
assert "30, 14 and 3 days" in detail
def test_an_hour_left_is_reported_as_a_non_event():
state, detail = verdict([{"name": "GITHUB_TOKEN", "state": "short-lived",
"seconds_left": 3540}])
assert state == "short-lived"
assert "59 minute(s)" in detail
assert "does not distinguish them" in detail
def test_no_expiry_is_a_finding_and_not_a_clean_bill_of_health():
state, detail = verdict([{"name": "GITHUB_BOT_TOKEN",
"state": "no-expiry-reported", "seconds_left": None}])
assert state == "no-expiry-reported"
assert "larger standing risk" in detail
def test_nothing_named_is_reported_as_nothing_checked():
assert verdict([])[0] == "nothing-checked"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
bucket, headerValue, parseExpiry, reading, schedule, secondsLeft, verdict,
} from './github-token-expiry-watch.mjs';
const NOON = 1790769600; // 2026-09-30 12:00:00 UTC
test('the documented shape parses to the right instant', () => {
assert.equal(parseExpiry('2026-09-30 12:00:00 UTC'), NOON);
});
test('the iso shapes parse to the same instant', () => {
assert.equal(parseExpiry('2026-09-30T12:00:00Z'), NOON);
assert.equal(parseExpiry('2026-09-30T12:00:00+00:00'), NOON);
});
test('a numeric offset is honoured rather than ignored', () => {
assert.equal(parseExpiry('2026-09-30 07:00:00 -0500'), NOON);
assert.equal(parseExpiry('2026-09-30 14:00:00 +02:00'), NOON);
});
test('a bare date is read as midnight utc', () => {
assert.equal(parseExpiry('2026-09-30'), NOON - 12 * 3600);
});
test('a shape that is not recognised returns nothing at all', () => {
assert.equal(parseExpiry('soon'), null);
assert.equal(parseExpiry('30/09/2026'), null);
assert.equal(parseExpiry(''), null);
assert.equal(parseExpiry(null), null);
});
test('the header is found whatever its case', () => {
assert.equal(headerValue({ 'Github-Authentication-Token-Expiration': 'x' }), 'x');
assert.equal(headerValue({ 'github-authentication-token-expiration': 'y' }), 'y');
assert.equal(headerValue({ etag: 'z' }), null);
assert.equal(headerValue(null), null);
});
test('remaining time is a number or nothing', () => {
assert.equal(secondsLeft(NOON, NOON - 60), 60);
assert.equal(secondsLeft(null, NOON), null);
assert.equal(secondsLeft(NOON, 'later'), null);
});
test('the thresholds bucket as advertised', () => {
assert.equal(bucket(null), 'unknown');
assert.equal(bucket(0), 'expired');
assert.equal(bucket(-1), 'expired');
assert.equal(bucket(3600), 'short-lived');
assert.equal(bucket(2 * 86400), 'critical');
assert.equal(bucket(10 * 86400), 'warning');
assert.equal(bucket(20 * 86400), 'notice');
assert.equal(bucket(90 * 86400), 'ok');
});
test('custom thresholds are respected', () => {
assert.equal(bucket(20 * 86400, [60, 30, 21]), 'critical');
});
test('a successful request with no header is its own state', () => {
const row = reading('GITHUB_TOKEN', 200, { etag: 'abc' }, NOON);
assert.equal(row.state, 'no-expiry-reported');
assert.match(row.why, /never expires or its class does not report one/);
});
test('a failed request is not the same silence', () => {
assert.equal(reading('GITHUB_TOKEN', 500, {}, NOON).state, 'unreadable');
assert.equal(reading('GITHUB_TOKEN', 0, {}, NOON).state, 'unreadable');
});
test('a refused credential has no forecast left', () => {
assert.equal(reading('GITHUB_TOKEN', 401, {}, NOON).state, 'rejected');
});
test('an unparseable header is reported rather than guessed', () => {
const row = reading('GITHUB_TOKEN', 200,
{ 'github-authentication-token-expiration': 'next tuesday' }, NOON);
assert.equal(row.state, 'unreadable-header');
assert.match(row.why, /did not parse/);
});
test('a live reading carries the remaining seconds', () => {
const headers = { 'github-authentication-token-expiration': '2026-09-30 12:00:00 UTC' };
const row = reading('GITHUB_TOKEN', 200, headers, NOON - 2 * 86400);
assert.equal(row.state, 'critical');
assert.equal(row.seconds_left, 2 * 86400);
assert.equal(row.expires_at, NOON);
});
test('an unreadable credential outranks a healthy one', () => {
const rows = [
{ name: 'b', state: 'ok', seconds_left: 90 * 86400 },
{ name: 'a', state: 'unreadable', seconds_left: null },
{ name: 'c', state: 'critical', seconds_left: 2 * 86400 },
];
assert.deepEqual(schedule(rows).map((row) => row.name), ['c', 'a', 'b']);
});
test('the soonest wins inside one state', () => {
const rows = [
{ name: 'later', state: 'warning', seconds_left: 12 * 86400 },
{ name: 'sooner', state: 'warning', seconds_left: 8 * 86400 },
];
assert.equal(schedule(rows)[0].name, 'sooner');
});
test('the verdict is the top row', () => {
const ordered = schedule([{ name: 'GITHUB_CI_TOKEN', state: 'critical', seconds_left: 2 * 86400 }]);
const [state, detail] = verdict(ordered);
assert.equal(state, 'critical');
assert.match(detail, /2\.0 day\(s\)/);
assert.match(detail, /30, 14 and 3 days/);
});
test('an hour left is reported as a non event', () => {
const [state, detail] = verdict([{ name: 'GITHUB_TOKEN', state: 'short-lived', seconds_left: 3540 }]);
assert.equal(state, 'short-lived');
assert.match(detail, /59 minute\(s\)/);
assert.match(detail, /does not distinguish them/);
});
test('no expiry is a finding and not a clean bill of health', () => {
const [state, detail] = verdict([{ name: 'GITHUB_BOT_TOKEN', state: 'no-expiry-reported', seconds_left: null }]);
assert.equal(state, 'no-expiry-reported');
assert.match(detail, /larger standing risk/);
});
test('nothing named is reported as nothing checked', () => {
assert.equal(verdict([])[0], 'nothing-checked');
});
FAQ
Which request should I use to read the expiry header?
Any authenticated one, which makes GET /rate_limit the obvious choice because it consumes no quota from any bucket. That matters when the check runs on a schedule across several credentials: the monitoring costs nothing it is monitoring. GET /user works too but is refused by an Actions token, so it fails on exactly the credential class you might want to include.
Can I list every token on the account and check them all?
No. A read-only credential cannot enumerate the tokens on an account, so the API can only tell you about the credential you are currently holding. Fleet coverage has to come from your side: name every environment variable that holds one and check them in the same run. Anything not in that list is not being watched, and the script cannot know it exists.
The header is missing. Does that mean the token never expires?
It means one of two things and the header will not say which. Classic personal access tokens can be minted with no expiry at all, and some credential classes simply do not surface a date. The script reports no-expiry-reported rather than picking one, because a permanent token treated as healthy is exactly the failure the check exists to prevent.
How far ahead should the alert fire?
Thirty days is when a rotation can be planned, fourteen is when it fits in a sprint, and three is when it becomes an interrupt. The reason for three thresholds rather than one is that a single alarm gets snoozed once and then forgotten, whereas an escalating one is hard to ignore twice. Zero is not a threshold, it is an incident.
Our App installation token shows about an hour. Should I alert on that?
No, that is the migration working. Installation tokens are minted on demand and live about an hour by design, so a short life there means nobody has to remember anything. The script labels it short-lived rather than critical for that reason, while saying plainly that a personal access token in its final two hours looks identical from the header alone.
Related field notes
- A classic PAT passed its expiry date
- 401 Bad credentials on every endpoint
- An App installation covers only some repos
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
- Authenticating to the REST API — GitHub Docs
- Authenticating as a GitHub App installation — GitHub Docs
- Troubleshooting the REST API — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.