Diagnostic GitHub API
The SAML session lapsed and the authorization went with it
Nobody touched anything. The nightly job ran for six weeks, and this morning it returned 403 on every organization call with a message about SAML enforcement. Somebody logs into GitHub in a browser to check, comes back, reruns the job, and it works. The ticket gets closed as transient. Eight days later it happens again, at a different hour, and the second ticket says the API is flaky. It is not flaky and it is not random: the authorization that credential holds against the organization has an expiry date, the organization set the interval, and the browser login that “fixed” it was a person quietly renewing something they did not know they were renewing.
Authorizing a token for an SSO-enforced organization is not permanent. The authorization is tied to a SAML session with the identity provider, and organizations can require re-authentication on an interval, so a credential that was authorized correctly starts being refused again when that session lapses. The refusal is identical to the one a never-authorized token gets, which is why this reads as flakiness.
There is a dated record behind it. With admin:org, GET /orgs/{org}/credential-authorizations lists every SSO-authorized credential with credential_id, credential_type, token_last_eight, credential_accessed_at and — the field this note is about — authorized_credential_expires_at.
That last field turns an outage into a calendar entry. Match your credential to its record by the last eight characters, read the expiry, and you know the date the job will start failing before it does. The durable repair is not a better reminder: it is an App installation token, which does not depend on any human's session.
The problem in plain words
The signature of this problem is that it fixes itself. Someone investigates, opens GitHub in a browser to look at the organization, authenticates through the identity provider because that is what opening GitHub does at a company with SSO, and by the time they get back to the terminal the failing call succeeds. That sequence teaches exactly the wrong lesson. The retry looked like the fix, so the runbook that gets written says “rerun it”, and the actual cause — a human's browser login renewing a session the automation depends on — is invisible to everyone including the person who did it.
Then it recurs on a period nobody recognises. The organization's re-authentication interval might be a week, a fortnight or a day, and the lapse lands wherever the clock lands: mid-run, over a weekend, on a bank holiday. Because it is a duration since a login rather than a time of day, the failures do not line up on a chart, and the pattern-matching people are good at finds nothing. Two of these tickets in a quarter get filed under transient. Six of them get filed under “GitHub is unreliable”.
The cruellest version is the one where nothing recurs for months because the person who owns the token happens to be at their desk every morning. Their daily browser login keeps the session alive and the automation alive with it. Then they go on leave, and the job that has never failed in a year fails on day three of their holiday, in a way nobody else can reproduce and nobody thinks to connect to an absence.
Why it happens
The authorization has a lifetime, and the lifetime is the organization's to set. Authorizing a credential records a grant against your SAML identity, not a permanent property of the token. When the organization requires re-authentication and the session lapses, previously authorized credentials go back to being refused. Nothing about the token changed — it has not expired, it has not been revoked, its scopes are the same — which is precisely why every check anyone runs on the token comes back clean.
The refusal is byte-for-byte the one a new token gets. Same status, same message, same x-github-sso: required header with a fresh URL. So the header alone cannot tell a lapse from a first authorization; it only tells you SAML is involved. What separates them is history: did this credential ever succeed here. That history is not in the response, and a read-only token cannot enumerate its own past, which is why the two cases need two different readings and get two different notes.
The authoritative reading needs an owner, and it is worth the borrow. GET /orgs/{org}/credential-authorizations is an admin:org endpoint, so the credential being diagnosed usually cannot read it — you run this with a second, administrative credential and ask about the first. That is unusual for this section and it is the point: the record contains a date the failing credential is not allowed to know about itself.
Matching happens locally and the eight characters never leave. Records identify credentials by token_last_eight. The script computes the last eight of the credential in the environment, compares in memory, and prints only whether a record matched. It never logs those characters and never puts them in its JSON, because a fragment of a live credential in a CI log is still a fragment of a live credential, and eight characters is enough to correlate a token across systems.
The expiry field converts this from an outage into a forecast. This is the whole reason to run the script when nothing is broken. authorized_credential_expires_at says when the grant stops; credential_accessed_at says when it was last used and therefore proves it did work. A run that reports “active, three days left, last used four hours ago” is a warning with a date on it, and it is the only form of this problem that can be dealt with calmly.
And the honest limit: the interval itself is not published. The API gives you this grant's expiry, not the organization's re-authentication policy, so the script reports the window it can measure and says the cadence is inferred rather than read. That is sufficient for the decision that matters, because the decision is not “how often should we re-authorize”. It is “should an unattended job depend on a human's session at all”, and the answer to that one does not need a number.
The fix, as a flow
This script holds two credentials and asks one about the other. The one in trouble supplies eight characters for a match that happens in memory and is never printed; an owner's credential reads the authorization record, because a credential is not allowed to see its own grant. What comes back is a date. Expired yesterday, expiring in three days and good for another month are three different sentences, and only the second one is worth being woken up for, which is why the best run of this script is the one made while everything still works. No record at all is its own finding: a credential that was never authorized is the sibling note rather than a lapse.
How to fix it
Confirm the credential is refused and that SAML is why
Two reads with the credential under investigation: GET /user to establish it is alive, and one organization listing to see whether it is currently refused and whether x-github-sso is on the response. This is deliberately the same evidence the first-authorization note gathers, because up to here the two problems are genuinely identical and pretending otherwise would be a lie in the shape of a script.
Borrow an administrative credential for the record
GITHUB_ADMIN_TOKEN, held by an organization owner, reads GET /orgs/{org}/credential-authorizations. The credential being diagnosed cannot read this and does not need to: it supplies the last eight characters to match on and nothing else. Without the admin credential the script says plainly that the lapse cannot be proven and that the header reading is all you have.
Match on the last eight, in memory, and print none of them
The comparison is local. The script derives the last eight characters of the credential in the environment, finds the record whose token_last_eight equals it, and reports matched or no matching record. Those characters appear in no log line and in no JSON field. A record that does not exist is its own finding: a credential with no authorization record was never authorized, which is the sibling note rather than this one.
Read the two dates and turn them into a sentence
authorized_credential_expires_at against the current time gives lapsed, expiring soon, or active with a number of days on it. credential_accessed_at proves the credential really was working, which is the fact that makes this a lapse rather than a first authorization. The output is a date, not an adjective.
Print the renewal, then print the way out of renewing
The immediate repair is the same click as the first time, and the script says so without pretending it is a solution: it will be needed again, on an interval the API does not publish. The second line is the one worth acting on — an App installation token belongs to an installation the organization approved and does not lapse when a person stops logging in. Nothing is renewed, authorized or requested by this script.
How to check it worked
Run it while everything works. A number of days is a better finding than an outage.
GITHUB_TOKEN=$READ_ONLY_TOKEN GITHUB_ADMIN_TOKEN=$OWNER_TOKEN \
python3 github_sso_session_clock.py acme-corp
# read cost: 3 request(s) against the core hourly quota
# credential: classic PAT, account=octobot
# GET /orgs/acme-corp/repos -> 200, x-github-sso: absent
# credential-authorizations: 214 record(s) read, 1 matched
# (matched on the last eight characters, in memory; they are not printed)
# credential_type=personal access token credential_accessed_at=2026-08-31T04:11:07Z
# authorized_credential_expires_at=2026-09-03T09:22:41Z
# authorization-expiring: this authorization lapses in 3 day(s). The credential
# is fine; the organization's SAML session behind it is what runs out.
# cadence: the organization's re-authentication interval is not published by the
# API. What is readable is this grant's expiry, and it will recur.
# repair: a person re-authenticates at https://github.com/orgs/acme-corp/sso
# before that date. This script does not and will not do it. For the nightly
# job, move to an App installation token, which never lapses with a person's
# identity-provider session.
The full code
Two things make this script different from every other credential check in the section. It holds two credentials and asks one about the other, because the record that carries the date is an owner-only read and the credential in trouble is not allowed to see its own grant. And it does a match it refuses to print: the last eight characters identify a record, they are compared in memory, and they appear in no output, because eight live characters in a CI log are still eight live characters. Everything else is date arithmetic against authorized_credential_expires_at, which is the field that turns this from an incident into a diary entry.
"""Read the expiry on a SAML credential authorization before it lapses.
Read only, and it authorizes nothing. The repair for a lapsed SAML session is a
person re-authenticating in a browser; this script reports the date that will
become necessary and never performs it.
Two credentials, on purpose. GITHUB_TOKEN is the credential in trouble.
GITHUB_ADMIN_TOKEN belongs to an organization owner and is the only one that
can read GET /orgs/{org}/credential-authorizations, where the dated record
lives. The credential being diagnosed is not permitted to know its own expiry,
which is the reason this note needs a second reader at all.
The match on token_last_eight happens in memory and those characters are never
logged or serialised. Eight characters of a live credential are still part of a
live credential, and they are enough to correlate one across systems.
What this can and cannot see: it can read this grant's expiry, when the
credential was last used, and therefore whether a refusal is a lapse or a
credential that was never authorized. It cannot read the organization's
re-authentication interval, which is not published by the API, so the cadence is
reported as inferred rather than measured.
Environment:
GITHUB_TOKEN the credential being diagnosed
GITHUB_ADMIN_TOKEN an organization owner's credential, admin:org (optional)
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_sso_session_clock")
API = "https://api.github.com"
UA = "github-sso-session-clock/1.0"
SSO_HEADER = "x-github-sso"
# Inside this window the answer is "arrange it now" rather than "it is fine".
EXPIRING_SOON_DAYS = 7
TOKEN_PREFIXES = (
("github_pat_", "fine-grained PAT"),
("ghp_", "classic PAT"),
("gho_", "OAuth user token"),
("ghu_", "App user-to-server token"),
("ghs_", "App installation token"),
("ghr_", "App refresh token"),
)
# Credentials that hang off a person's identity-provider session, and therefore
# lapse when it does. The installation token is the one that does not, which is
# the entire durable repair.
LAPSES_WITH_A_PERSON = {
"classic PAT": True,
"OAuth user token": True,
"App user-to-server token": True,
"fine-grained PAT": True,
"App installation token": False,
"App refresh token": False,
"unknown": True,
}
def read_cost(with_admin=True, pages=1):
"""Requests this run will spend against the core quota. Pure.
Two reads with the credential under investigation, plus one page of
authorization records per page walked with the owner's credential.
"""
return 2 + (pages if with_admin else 0)
def token_kind(token):
"""Name the credential from its prefix. Pure; nothing leaves the machine."""
value = (token or "").strip()
for prefix, name in TOKEN_PREFIXES:
if value.startswith(prefix):
return name
return "unknown"
def last_eight(token):
"""The eight characters a record is matched on. Pure.
Returned for an in-memory comparison only. Every caller in this script
keeps the result out of logs and out of the report.
"""
value = (token or "").strip()
return value[-8:] if len(value) >= 8 else ""
def match_authorization(records, tail):
"""Find the record for this credential. Pure.
Compares token_last_eight and returns the record or None. A missing match
is a real finding rather than an error: a credential with no authorization
record has never been authorized for this organization.
"""
if not tail:
return None
for record in records or []:
if not isinstance(record, dict):
continue
if str(record.get("token_last_eight") or "") == tail:
return record
return None
def parse_ts(value):
"""ISO 8601 with a Z into an aware datetime, or None. Pure."""
if not value:
return None
text = str(value).strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def days_left(expires_at, now):
"""Whole days from now until the grant lapses, negative if it has. Pure."""
when = parse_ts(expires_at)
if when is None:
return None
return int((when - now).total_seconds() // 86400)
def authorization_state(record, now, refused):
"""Classify one credential's SAML standing. Pure. (state, detail).
The record and the current refusal are read together, because either alone
gets a case wrong: a missing record with no refusal is simply an
organization that does not enforce SAML, and an active record beside a
refusal is a refusal SAML does not explain.
"""
if record is None:
if refused:
return ("never-authorized",
"no authorization record exists for this credential, so it "
"has never been authorized for this organization. That is a "
"first authorization rather than a lapse, and a different "
"note owns it.")
return ("no-record-no-refusal",
"no authorization record and nothing being refused, which is "
"what an organization that does not enforce SAML looks like.")
remaining = days_left(record.get("authorized_credential_expires_at"), now)
if remaining is None:
return ("expiry-not-published",
"the record exists but carries no expiry, so this grant is not "
"on a clock the API will show you. A refusal here is worth "
"re-reading the header for.")
if remaining < 0:
return ("authorization-lapsed",
"this authorization expired %d day(s) ago. The credential is "
"unchanged and valid; the SAML session behind it ran out."
% abs(remaining))
if remaining <= EXPIRING_SOON_DAYS:
return ("authorization-expiring",
"this authorization lapses in %d day(s). The credential is "
"fine; the organization's SAML session behind it is what runs "
"out." % remaining)
return ("authorization-active",
"this authorization is good for another %d day(s)." % remaining)
def lapse_evidence(record):
"""Did this credential demonstrably work here. Pure. (bool, detail)."""
if record is None:
return (False, "no record, so there is no evidence of past use.")
used = parse_ts(record.get("credential_accessed_at"))
if used is None:
return (False, "the record carries no last-used time, so past success "
"is not provable from it.")
return (True, "the record was last used at %s, which proves this credential "
"did work against this organization." % used.isoformat())
def cadence_note(state):
"""What recurrence a reader should expect. Pure."""
if state in ("authorization-lapsed", "authorization-expiring",
"authorization-active"):
return ("the organization's re-authentication interval is not published "
"by the API. What is readable is this grant's expiry, and it "
"will recur.")
return ("nothing to forecast from this reading.")
def unattended_verdict(kind):
"""Does this credential type depend on a person staying logged in. Pure."""
if LAPSES_WITH_A_PERSON.get(kind, True):
return (True, "a %s hangs off a person's identity-provider session, so "
"an unattended job holding one fails whenever that person "
"stops logging in." % kind)
return (False, "a %s does not depend on anyone's identity-provider session, "
"which is why it is the answer for unattended work." % kind)
def repair(state, org, kind):
"""The sentence a reader has to act on. Pure."""
depends, _ = unattended_verdict(kind)
renew = ("a person re-authenticates at https://github.com/orgs/%s/sso "
"before that date. This script does not and will not do it."
% org)
if state == "authorization-lapsed":
return (renew.replace("before that date", "to restore this credential")
+ (" For anything unattended, move to an App installation "
"token, which never lapses with a person's session."
if depends else ""))
if state == "authorization-expiring":
return (renew + (" For the job that depends on this, move to an App "
"installation token, which never lapses with a "
"person's session." if depends else ""))
if state == "never-authorized":
return ("authorize the credential for the first time, which is the "
"sibling problem: the refusal is the same and the repair does "
"not recur on a session clock.")
if state == "authorization-active":
return ("nothing today. Note the date and decide whether an unattended "
"job should be depending on a human session at all.")
if state == "expiry-not-published":
return ("read the refusal's x-github-sso header instead; this record "
"will not tell you when the grant ends.")
return "nothing on SAML here."
def get(session, url):
"""One GET. Returns the response object."""
r = session.get(url, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: a credential is missing, malformed "
"or revoked. That is a different note.")
return r
def session_for(token):
s = requests.Session()
s.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
})
return s
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("org", help="the organization enforcing SAML")
ap.add_argument("--pages", type=int, default=1,
help="pages of credential authorizations to walk")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (the credential being diagnosed)")
return 2
admin = os.environ.get("GITHUB_ADMIN_TOKEN")
log.info("read cost: %d request(s) against the core hourly quota",
read_cost(bool(admin), max(1, args.pages)))
kind = token_kind(token)
subject = session_for(token)
me = get(subject, API + "/user")
account = (me.json() or {}).get("login") if me.status_code == 200 else None
log.info("credential: %s, account=%s", kind, account or "unreadable")
listing = get(subject, API + "/orgs/%s/repos?per_page=1" % args.org)
sso_form = (listing.headers.get(SSO_HEADER) or "").split(";")[0].strip().lower()
log.info("GET /orgs/%s/repos -> %s, %s: %s", args.org, listing.status_code,
SSO_HEADER, sso_form or "absent")
refused = listing.status_code in (403, 404)
records = []
if admin:
owner = session_for(admin)
url = (API + "/orgs/%s/credential-authorizations?per_page=100"
% args.org)
for _page in range(max(1, args.pages)):
page = get(owner, url)
if page.status_code != 200:
log.warning("credential-authorizations returned HTTP %s. That "
"endpoint needs admin:org, so this is usually the "
"wrong credential rather than a missing record.",
page.status_code)
break
body = page.json()
records.extend(body if isinstance(body, list) else [])
nxt = (page.links or {}).get("next", {}).get("url")
if not nxt:
break
url = nxt
else:
log.warning("no GITHUB_ADMIN_TOKEN, so the dated record cannot be read. "
"Without it a lapse and a first authorization look "
"identical, and the header is all you have.")
# Compared in memory. These characters are never logged and never appear in
# the report below.
record = match_authorization(records, last_eight(token))
if admin:
log.info("credential-authorizations: %d record(s) read, %d matched "
"(matched on the last eight characters, in memory; they are "
"not printed)", len(records), 1 if record else 0)
if record:
log.info("credential_type=%s credential_accessed_at=%s",
record.get("credential_type"), record.get("credential_accessed_at"))
log.info("authorized_credential_expires_at=%s",
record.get("authorized_credential_expires_at"))
now = datetime.now(timezone.utc)
state, detail = authorization_state(record, now, refused)
proven, proof = lapse_evidence(record)
log.info("%s: %s", state, detail)
log.info("past use: %s", proof)
log.info("cadence: %s", cadence_note(state))
depends, depends_detail = unattended_verdict(kind)
log.info("unattended: %s", depends_detail)
log.info("repair: %s", repair(state, args.org, kind))
print(json.dumps({
"organization": args.org,
"account": account,
"credential_kind": kind,
"listing_status": listing.status_code,
"sso_form": sso_form or None,
"records_read": len(records),
"record_matched": bool(record),
"credential_type": (record or {}).get("credential_type"),
"credential_accessed_at": (record or {}).get("credential_accessed_at"),
"authorized_credential_expires_at":
(record or {}).get("authorized_credential_expires_at"),
"days_left": days_left((record or {}).get(
"authorized_credential_expires_at"), now),
"state": state,
"detail": detail,
"past_use_proven": proven,
"depends_on_a_person": depends,
"repair": repair(state, args.org, kind),
}, indent=2, default=str))
return 1 if state in ("authorization-lapsed", "authorization-expiring") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Read the expiry on a SAML credential authorization before it lapses.
*
* Read only, and it authorizes nothing. The repair for a lapsed SAML session is
* a person re-authenticating in a browser; this reports the date that will
* become necessary and never performs it.
*
* Two credentials on purpose: the one in trouble, and an organization owner one
* that can read GET /orgs/{org}/credential-authorizations, where the dated
* record lives. The match on token_last_eight happens in memory and those
* characters are never logged or serialised.
*
* Environment:
* GITHUB_TOKEN the credential being diagnosed
* GITHUB_ADMIN_TOKEN an organization owner credential with admin:org
* GITHUB_ORG the organization enforcing SAML
*/
const API = 'https://api.github.com';
const UA = 'github-sso-session-clock/1.0';
export const SSO_HEADER = 'x-github-sso';
export const EXPIRING_SOON_DAYS = 7;
export const TOKEN_PREFIXES = [
['github_pat_', 'fine-grained PAT'],
['ghp_', 'classic PAT'],
['gho_', 'OAuth user token'],
['ghu_', 'App user-to-server token'],
['ghs_', 'App installation token'],
['ghr_', 'App refresh token'],
];
/** Credentials that hang off a person identity-provider session. */
export const LAPSES_WITH_A_PERSON = {
'classic PAT': true,
'OAuth user token': true,
'App user-to-server token': true,
'fine-grained PAT': true,
'App installation token': false,
'App refresh token': false,
unknown: true,
};
/** Requests this run will spend against the core quota. Pure. */
export function readCost(withAdmin = true, pages = 1) {
return 2 + (withAdmin ? pages : 0);
}
/** Name the credential from its prefix. Pure. */
export function tokenKind(token) {
const value = String(token ?? '').trim();
for (const [prefix, name] of TOKEN_PREFIXES) {
if (value.startsWith(prefix)) return name;
}
return 'unknown';
}
/** The eight characters a record is matched on. Pure, and never printed. */
export function lastEight(token) {
const value = String(token ?? '').trim();
return value.length >= 8 ? value.slice(-8) : '';
}
/** Find the record for this credential. Pure. */
export function matchAuthorization(records, tail) {
if (!tail) return null;
for (const record of records || []) {
if (!record || typeof record !== 'object') continue;
if (String(record.token_last_eight ?? '') === tail) return record;
}
return null;
}
/** ISO 8601 into epoch milliseconds, or null. Pure. */
export function parseTs(value) {
if (!value) return null;
const ms = Date.parse(String(value));
return Number.isNaN(ms) ? null : ms;
}
/** Whole days until the grant lapses, negative if it has. Pure. */
export function daysLeft(expiresAt, nowMs) {
const when = parseTs(expiresAt);
if (when === null) return null;
return Math.floor((when - nowMs) / 86400000);
}
/** Classify one credential SAML standing. Pure. [state, detail]. */
export function authorizationState(record, nowMs, refused) {
if (!record) {
if (refused) {
return ['never-authorized', 'no authorization record exists for this '
+ 'credential, so it has never been authorized for this organization. '
+ 'That is a first authorization rather than a lapse.'];
}
return ['no-record-no-refusal', 'no authorization record and nothing being '
+ 'refused, which is what an organization without SAML looks like.'];
}
const remaining = daysLeft(record.authorized_credential_expires_at, nowMs);
if (remaining === null) {
return ['expiry-not-published', 'the record exists but carries no expiry, so '
+ 'this grant is not on a clock the API will show you.'];
}
if (remaining < 0) {
return ['authorization-lapsed', `this authorization expired ${Math.abs(remaining)} `
+ 'day(s) ago. The credential is unchanged and valid; the SAML session '
+ 'behind it ran out.'];
}
if (remaining <= EXPIRING_SOON_DAYS) {
return ['authorization-expiring', `this authorization lapses in ${remaining} `
+ 'day(s). The credential is fine; the SAML session behind it runs out.'];
}
return ['authorization-active', `this authorization is good for another ${remaining} day(s).`];
}
/** Did this credential demonstrably work here. Pure. */
export function lapseEvidence(record) {
if (!record) return [false, 'no record, so there is no evidence of past use.'];
const used = parseTs(record.credential_accessed_at);
if (used === null) {
return [false, 'the record carries no last-used time, so past success is not '
+ 'provable from it.'];
}
return [true, `the record was last used at ${new Date(used).toISOString()}, `
+ 'which proves this credential did work against this organization.'];
}
/** What recurrence a reader should expect. Pure. */
export function cadenceNote(state) {
const clocked = ['authorization-lapsed', 'authorization-expiring',
'authorization-active'];
if (clocked.includes(state)) {
return 'the organization re-authentication interval is not published by the '
+ 'API. What is readable is this grant expiry, and it will recur.';
}
return 'nothing to forecast from this reading.';
}
/** Does this credential type depend on a person staying logged in. Pure. */
export function unattendedVerdict(kind) {
if (LAPSES_WITH_A_PERSON[kind] ?? true) {
return [true, `a ${kind} hangs off a person identity-provider session, so an `
+ 'unattended job holding one fails whenever that person stops logging in.'];
}
return [false, `a ${kind} does not depend on anyone identity-provider session, `
+ 'which is why it is the answer for unattended work.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state, org, kind) {
const [depends] = unattendedVerdict(kind);
const moveOff = depends ? ' For anything unattended, move to an App '
+ 'installation token, which never lapses with a person session.' : '';
const renew = `a person re-authenticates at https://github.com/orgs/${org}/sso. `
+ 'This script does not and will not do it.';
if (state === 'authorization-lapsed') return renew + moveOff;
if (state === 'authorization-expiring') return `${renew} Do it before that date.${moveOff}`;
if (state === 'never-authorized') {
return 'authorize the credential for the first time, which is the sibling '
+ 'problem: the refusal is the same and the repair does not recur.';
}
if (state === 'authorization-active') {
return 'nothing today. Note the date and decide whether an unattended job '
+ 'should depend on a human session at all.';
}
if (state === 'expiry-not-published') {
return 'read the refusal x-github-sso header instead; this record will not '
+ 'tell you when the grant ends.';
}
return 'nothing on SAML here.';
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const org = process.env.GITHUB_ORG;
if (!token || !org) {
console.error('set GITHUB_TOKEN (the credential being diagnosed) and GITHUB_ORG');
process.exitCode = 2;
return;
}
const admin = process.env.GITHUB_ADMIN_TOKEN;
console.log(`read cost: ${readCost(Boolean(admin))} request(s) against the core hourly quota`);
const kind = tokenKind(token);
const me = await fetch(`${API}/user`, { headers: headers(token) });
const account = me.status === 200 ? (await me.json()).login : null;
console.log(`credential: ${kind}, account=${account ?? 'unreadable'}`);
const listing = await fetch(`${API}/orgs/${org}/repos?per_page=1`,
{ headers: headers(token) });
const ssoForm = (listing.headers.get(SSO_HEADER) || '').split(';')[0].trim().toLowerCase();
console.log(`GET /orgs/${org}/repos -> ${listing.status}, ${SSO_HEADER}: ${ssoForm || 'absent'}`);
const refused = listing.status === 403 || listing.status === 404;
let records = [];
if (admin) {
const page = await fetch(
`${API}/orgs/${org}/credential-authorizations?per_page=100`,
{ headers: headers(admin) },
);
if (page.status === 200) {
const body = await page.json();
records = Array.isArray(body) ? body : [];
} else {
console.warn(`credential-authorizations returned HTTP ${page.status}; that `
+ 'endpoint needs admin:org.');
}
} else {
console.warn('no GITHUB_ADMIN_TOKEN, so the dated record cannot be read and a '
+ 'lapse looks exactly like a first authorization.');
}
// Compared in memory. These characters are never logged or serialised.
const record = matchAuthorization(records, lastEight(token));
console.log(`credential-authorizations: ${records.length} record(s) read, `
+ `${record ? 1 : 0} matched`);
const now = Date.now();
const [state, detail] = authorizationState(record, now, refused);
const [proven, proof] = lapseEvidence(record);
console.log(`${state}: ${detail}`);
console.log(`past use: ${proof}`);
console.log(`cadence: ${cadenceNote(state)}`);
const [depends, dependsDetail] = unattendedVerdict(kind);
console.log(`unattended: ${dependsDetail}`);
console.log(`repair: ${repair(state, org, kind)}`);
console.log(JSON.stringify({
organization: org,
account,
credential_kind: kind,
listing_status: listing.status,
sso_form: ssoForm || null,
records_read: records.length,
record_matched: Boolean(record),
credential_accessed_at: record?.credential_accessed_at ?? null,
authorized_credential_expires_at: record?.authorized_credential_expires_at ?? null,
days_left: daysLeft(record?.authorized_credential_expires_at, now),
state,
detail,
past_use_proven: proven,
depends_on_a_person: depends,
repair: repair(state, org, kind),
}, null, 2));
process.exitCode = ['authorization-lapsed', 'authorization-expiring'].includes(state) ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The first assertion is the one that keeps this note honest about its own limit: with no record to read, a lapse and a first authorization are indistinguishable, and the script has to say so rather than pick. Then the clock, which is the whole finding — expired yesterday, expiring in three days, and comfortably active have to be three different sentences with numbers in them. The last group is the discipline: the last eight characters are what the match runs on, so a test asserts they are absent from the report the script prints, because the moment they appear in a CI log they are a fragment of a live credential sitting in a log.
import json
from datetime import datetime, timedelta, timezone
from github_sso_session_clock import (
authorization_state, cadence_note, days_left, lapse_evidence, last_eight,
match_authorization, parse_ts, read_cost, repair, token_kind,
unattended_verdict,
)
NOW = datetime(2026, 8, 31, 12, 0, 0, tzinfo=timezone.utc)
def record(days, tail="fake1234", used="2026-08-31T04:11:07Z"):
when = NOW + timedelta(days=days)
return {
"credential_id": 161195,
"credential_type": "personal access token",
"token_last_eight": tail,
"credential_accessed_at": used,
"authorized_credential_expires_at": when.isoformat().replace("+00:00", "Z"),
}
def test_without_a_record_a_lapse_and_a_first_authorization_are_the_same():
# The honest limit of this note. No admin credential means no record, and
# the refusal alone cannot tell the two apart.
state, detail = authorization_state(None, NOW, refused=True)
assert state == "never-authorized"
assert "first authorization rather than a lapse" in detail
assert authorization_state(None, NOW, refused=False)[0] == "no-record-no-refusal"
def test_the_clock_produces_three_different_sentences():
assert authorization_state(record(-1), NOW, True)[0] == "authorization-lapsed"
assert authorization_state(record(3), NOW, False)[0] == "authorization-expiring"
assert authorization_state(record(30), NOW, False)[0] == "authorization-active"
def test_the_lapsed_verdict_counts_the_days_it_has_been_dead():
_state, detail = authorization_state(record(-4), NOW, True)
assert "4 day(s) ago" in detail
assert "credential is unchanged and valid" in detail
def test_the_expiring_verdict_is_a_forecast_with_a_number_on_it():
_state, detail = authorization_state(record(3), NOW, False)
assert "3 day(s)" in detail
assert days_left(record(3)["authorized_credential_expires_at"], NOW) == 3
assert days_left(record(-2)["authorized_credential_expires_at"], NOW) == -2
def test_a_record_with_no_expiry_is_not_reported_as_active():
bare = {"token_last_eight": "fake1234", "credential_accessed_at": None}
state, _ = authorization_state(bare, NOW, True)
assert state == "expiry-not-published"
def test_the_match_runs_on_the_last_eight_and_nothing_else():
tail = last_eight("ghp_fake1234")
assert tail == "fake1234"
records = [record(9, tail="other000"), record(2, tail=tail)]
assert match_authorization(records, tail)["token_last_eight"] == tail
assert match_authorization(records, "nomatch0") is None
assert match_authorization(records, "") is None
assert last_eight("short") == ""
def test_the_last_eight_never_reaches_the_report():
# The characters identify a record. They are also eight characters of a
# live credential, so nothing the script prints may contain them.
tail = last_eight("ghp_fake1234")
matched = match_authorization([record(5, tail=tail)], tail)
state, detail = authorization_state(matched, NOW, False)
report = json.dumps({
"state": state,
"detail": detail,
"record_matched": bool(matched),
"days_left": days_left(matched["authorized_credential_expires_at"], NOW),
"repair": repair(state, "acme-corp", "classic PAT"),
"cadence": cadence_note(state),
})
assert tail not in report
def test_past_use_is_what_proves_this_was_a_lapse():
proven, detail = lapse_evidence(record(-1))
assert proven is True and "did work against this organization" in detail
assert lapse_evidence(None)[0] is False
assert lapse_evidence(record(-1, used=None))[0] is False
def test_the_cadence_is_reported_as_inferred_not_measured():
note = cadence_note("authorization-expiring")
assert "not published" in note
assert "will recur" in note
assert cadence_note("no-record-no-refusal") == "nothing to forecast from this reading."
def test_an_installation_token_is_the_only_one_that_does_not_lapse():
depends, detail = unattended_verdict("classic PAT")
assert depends is True and "stops logging in" in detail
depends, detail = unattended_verdict("App installation token")
assert depends is False and "unattended work" in detail
def test_the_repair_renews_and_then_says_stop_renewing():
fix = repair("authorization-expiring", "acme-corp", "classic PAT")
assert "https://github.com/orgs/acme-corp/sso" in fix
assert "does not and will not do it" in fix
assert "App installation token" in fix
# Nothing is offered for a credential that does not lapse with a person.
assert "App installation token" not in repair(
"authorization-expiring", "acme-corp", "App installation token")
def test_a_missing_record_sends_the_reader_to_the_sibling_note():
fix = repair("never-authorized", "acme-corp", "classic PAT")
assert "first time" in fix
assert "does not recur" in fix
def test_timestamps_survive_both_spellings_of_utc():
assert parse_ts("2026-09-03T09:22:41Z") == parse_ts("2026-09-03T09:22:41+00:00")
assert parse_ts("not a date") is None
assert parse_ts(None) is None
def test_the_credential_type_comes_from_its_prefix():
assert token_kind("ghp_fake") == "classic PAT"
assert token_kind("ghs_fake") == "App installation token"
assert token_kind("nope") == "unknown"
def test_the_run_costs_two_reads_plus_the_record_pages():
assert read_cost(False) == 2
assert read_cost(True, 1) == 3
assert read_cost(True, 3) == 5
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
authorizationState, cadenceNote, daysLeft, lapseEvidence, lastEight,
matchAuthorization, parseTs, readCost, repair, tokenKind, unattendedVerdict,
} from './github-sso-session-clock.mjs';
const NOW = Date.parse('2026-08-31T12:00:00Z');
function record(days, tail = 'fake1234', used = '2026-08-31T04:11:07Z') {
return {
credential_id: 161195,
credential_type: 'personal access token',
token_last_eight: tail,
credential_accessed_at: used,
authorized_credential_expires_at: new Date(NOW + days * 86400000).toISOString(),
};
}
test('without a record a lapse and a first authorization are the same', () => {
assert.equal(authorizationState(null, NOW, true)[0], 'never-authorized');
assert.equal(authorizationState(null, NOW, false)[0], 'no-record-no-refusal');
});
test('the clock produces three different sentences', () => {
assert.equal(authorizationState(record(-1), NOW, true)[0], 'authorization-lapsed');
assert.equal(authorizationState(record(3), NOW, false)[0], 'authorization-expiring');
assert.equal(authorizationState(record(30), NOW, false)[0], 'authorization-active');
});
test('the expiring verdict is a forecast with a number on it', () => {
const [, detail] = authorizationState(record(3), NOW, false);
assert.ok(detail.includes('3 day(s)'));
assert.equal(daysLeft(record(3).authorized_credential_expires_at, NOW), 3);
assert.equal(daysLeft(record(-2).authorized_credential_expires_at, NOW), -2);
});
test('a record with no expiry is not reported as active', () => {
assert.equal(authorizationState({ token_last_eight: 'fake1234' }, NOW, true)[0],
'expiry-not-published');
});
test('the match runs on the last eight and nothing else', () => {
const tail = lastEight('ghp_fake1234');
assert.equal(tail, 'fake1234');
const records = [record(9, 'other000'), record(2, tail)];
assert.equal(matchAuthorization(records, tail).token_last_eight, tail);
assert.equal(matchAuthorization(records, 'nomatch0'), null);
assert.equal(lastEight('short'), '');
});
test('the last eight never reaches the report', () => {
const tail = lastEight('ghp_fake1234');
const matched = matchAuthorization([record(5, tail)], tail);
const [state, detail] = authorizationState(matched, NOW, false);
const report = JSON.stringify({
state, detail, record_matched: Boolean(matched),
repair: repair(state, 'acme-corp', 'classic PAT'),
cadence: cadenceNote(state),
});
assert.ok(!report.includes(tail));
});
test('past use is what proves this was a lapse', () => {
assert.equal(lapseEvidence(record(-1))[0], true);
assert.equal(lapseEvidence(null)[0], false);
assert.equal(lapseEvidence(record(-1, 'fake1234', null))[0], false);
});
test('the cadence is reported as inferred not measured', () => {
assert.ok(cadenceNote('authorization-expiring').includes('not published'));
assert.equal(cadenceNote('no-record-no-refusal'), 'nothing to forecast from this reading.');
});
test('an installation token is the only one that does not lapse', () => {
assert.equal(unattendedVerdict('classic PAT')[0], true);
assert.equal(unattendedVerdict('App installation token')[0], false);
});
test('the repair renews and then says stop renewing', () => {
const fix = repair('authorization-expiring', 'acme-corp', 'classic PAT');
assert.ok(fix.includes('https://github.com/orgs/acme-corp/sso'));
assert.ok(fix.includes('does not and will not do it'));
assert.ok(fix.includes('App installation token'));
assert.ok(!repair('authorization-expiring', 'acme-corp', 'App installation token')
.includes('App installation token'));
});
test('timestamps survive both spellings of utc', () => {
assert.equal(parseTs('2026-09-03T09:22:41Z'), parseTs('2026-09-03T09:22:41+00:00'));
assert.equal(parseTs('not a date'), null);
});
test('the credential type comes from its prefix', () => {
assert.equal(tokenKind('ghp_fake'), 'classic PAT');
assert.equal(tokenKind('ghs_fake'), 'App installation token');
});
test('the run costs two reads plus the record pages', () => {
assert.equal(readCost(false), 2);
assert.equal(readCost(true, 3), 5);
});
FAQ
How do I tell this apart from a token that was never authorized?
By history, and the API will not give you it from the refusal. Both produce the same status, the same message and the same <code>x-github-sso: required</code> header, so nothing on the failing response separates them. The separator is <code>GET /orgs/{org}/credential-authorizations</code>, which needs <code>admin:org</code>: a credential with a record was authorized and its grant has an expiry, and a credential with no record has <a href="/github/saml-token-not-authorized/">never been authorized at all</a>. Without an owner's credential you cannot prove which, and the script says so rather than picking.
Is this the same as the token itself expiring?
No. <a href="/github/token-expiring-soon/">Token expiry</a> is a property of the credential: it was created with a lifetime, the clock is the token's own, and when it runs out the token is dead everywhere including on your personal repositories. This is a property of a grant between that credential and one organization. The token stays perfectly alive and keeps working on everything else; one organization stops accepting it. Two different clocks, two different repairs, and a token can be well inside its own lifetime while its SAML authorization has already lapsed.
Why did logging into GitHub in a browser fix the job?
Because the browser login re-authenticated the person with the identity provider, and the credential's authorization is tied to that session. It is the most misleading fix in this section: it is done by a human, in a different application, minutes before somebody reruns the job and concludes the rerun worked. That is how this ends up in a runbook as “retry it”. It also explains the version of the problem that only happens when one particular person is on holiday, since their daily login was renewing the session for everybody.
Can the script re-authorize the credential when it sees the expiry coming?
It cannot and it should not. Renewing a SAML session means authenticating a human with an identity provider, which is the control the whole mechanism exists to enforce; a tool that could do it would be a way around the control rather than a feature of it. What the script does instead is give you a date, early enough to act on. If the answer to a date on a calendar is “we cannot schedule a human for that”, then the finding is not the date, it is that an unattended job is holding the wrong kind of credential.
Why does the script never print the last eight characters it matched on?
Because they are eight characters of a live credential and the output of a diagnostic script ends up in CI logs, tickets and pasted terminal transcripts. GitHub publishes <code>token_last_eight</code> on its own records for humans to eyeball in a settings page; that is a different context from a log line that gets indexed. The match is done in memory, the report says <code>record_matched: true</code>, and a test asserts those characters do not appear anywhere in what gets printed.
Related field notes
- The identical refusal, on a credential never authorized
- The other clock: the credential's own lifetime
- Why automation should not run as a person at all
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.
- Authorizing a personal access token for use with SAML single sign-on — GitHub Docs
- Organizations — GitHub REST API
- Enforcing SAML single sign-on for your organization — GitHub Docs
- Authenticating as a GitHub App installation — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.