Skip to content

Diagnostic GitHub API

The org caps token lifetime below your rotation interval

The runbook says rotate the integration token every year, and it has said that since the token was minted with a one-year expiry. This quarter the job started failing against one organization and only that one. The token still authenticates, GET /user answers 200, and everything against the personal account and the other organization keeps working, so it is obviously not an expiry. Except that somebody in that organization set a maximum lifetime for tokens reaching their resources, the token was minted before that and outlives the cap, and the annual rotation was never going to notice a rule that appeared in March.

Read-only token Python and Node.js Tests included
A phone showing app icons
Photo by Mika Baumeister on Unsplash
The short answer

An organization — or an enterprise above it — can set a maximum lifetime for fine-grained personal access tokens that access its resources. The default ceiling for a fine-grained token is 366 days and a policy can be much shorter. The failure is not a token that expired; it is a token whose granted lifetime is longer than the policy allows, which gets blocked at that organization while it keeps working everywhere else.

So the comparison to make is between two periods, not between a date and today. Read github-authentication-token-expiration off any authenticated response — GET /rate_limit is authenticated and costs no quota — and pair it with the date the token was issued, which only you have. Expiry minus issue is the lifetime you were actually granted. If that number is smaller than the interval in your rotation runbook, the integration breaks once every cycle forever, and no alert about days remaining will ever tell you why.

The policy itself is not readable through the API. Say so rather than inferring it: the script takes the cap as a declared number and reports the comparison, and where you hold a GitHub App with the right permission it can list every fine-grained token reaching the organization and the dates they die.

The problem in plain words

The first misdiagnosis is that this is an expiry, so somebody mints a fresh token and the job recovers. It recovers because the new token was created after the policy and the creation screen would not let them pick a longer lifetime than the cap. Nobody notices that, the incident is closed as "token had expired", and the same thing happens again on whatever schedule the new, shorter lifetime dictates. The rotation runbook is never touched, because as far as anybody can tell the process worked.

The second is that the failure is not global, and everything about a credential feels global. The token authenticates. It reads the personal account. It reads the other organization. Only one namespace refuses, which is the shape of a permission problem, so the investigation goes to scopes and repository selection and finds nothing wrong there because nothing is wrong there.

The third is that the section already has a note about the expiry clock, and this looks like it. It is not. A countdown answers "how many days does this token have left" and alerts you at thirty. That alert is useless against a policy shorter than your cycle, because the alert fires, somebody rotates, and the next token has the same too-short life. The number that needed watching was never the remaining days; it was the ceiling, compared against how often anybody is willing to do the rotation.

Owner sets amaximumin March, quietlyLong tokenstill validpolicy blocks, notshortensOne org refuseseverything elseanswersRotate, itrecoversnew token fits thecapRunbookuntouchedstill says yearlyand it happens again next quarter
No arrow here is a dead credential. The token is alive and correct and one namespace will not take it.

Why it happens

The policy blocks, it does not shorten. This is the fact the whole note turns on and the one people get wrong. Setting a maximum lifetime does not retroactively revoke or truncate existing tokens; GitHub's documentation is explicit that non-compliant tokens are blocked from accessing the organization. The credential is alive, correct and refused in one place, which is exactly why every global check on it comes back clean.

The lifetime you were granted is not readable from one response. The header gives you the expiry instant and nothing else. A token with 40 days left could be a 90-day token minted 50 days ago or a 366-day token nearly at the end of its life, and those two have completely different implications for the cap. The issue date is a fact only you hold, in whatever recorded minting a credential, which is why the script asks for it and reports lifetime-unknown rather than guessing when it is absent.

Two comparisons, and only one of them recurs. "This token expires before the next scheduled rotation" is a fact about this token, fixable once by rotating early. "The rotation interval is longer than any lifetime the policy permits" is a fact about the schedule, and it will produce an outage every cycle until the schedule changes. The script reports them as different states because they take different repairs: one is a calendar entry, the other is a process change or a move to a credential class with a different model entirely.

It applies to fine-grained tokens, and classic ones fail differently. The maximum-lifetime policy is a fine-grained personal access token policy. Classic tokens have no expiry requirement, so they are not covered by it — an organization restricts them by blocking classic access altogether, which is a different refusal. A classic token that quietly stops working after a long silence is the auto-revocation note instead. Getting the credential class right first stops three of the four wrong investigations.

App installation tokens sidestep the whole question. They live about an hour and are minted on demand, so a lifetime policy has nothing to constrain and a rotation runbook has nothing to schedule. That is the honest recommendation when the cap and the cadence cannot be reconciled: the problem is not the number of days, it is that a human-shaped credential is being used for a machine-shaped job.

The fix, as a flow

One free call carries the expiry, and one date you supply turns it into a lifetime. That subtraction is the whole method, because a token with forty days left could be a short one at its start or a long one near its end, and only one of those is a policy problem. The comparison that follows is between two periods rather than between a date and today, which is what separates this from a countdown: a rotation interval longer than any permitted lifetime fails once per cycle forever, and no alert about days remaining will ever say so. The cap itself is a declared number, because nothing documented reports it, and the script labels it as declared throughout rather than dressing an assumption as a reading.

Expiry, issue date, intervaltwo periods, one comparisonLifetime over the caprefused there, fine everywhere elseInterval longer than lifetimebreaks once per cycle, foreverThis token dies firstrotate early, the schedule is soundNo issue date suppliedunknown, and it stays unknownInterval inside the capand the alert reads the header
The middle rows share a symptom and differ in horizon. One is a calendar entry; the other is a schedule that cannot ever work.

How to fix it

Read the expiry off a call that costs nothing

GET /rate_limit is authenticated, returns the quota rather than spending it, and carries github-authentication-token-expiration like any other authenticated response. Read it case-insensitively. An absent header is a finding in itself and the script reports which of its two meanings applies from the credential class.

Supply the issue date, because only you have it

--issued 2026-03-01. Expiry minus issue is the lifetime the token was actually granted, and that number is what a policy caps. Without it the script will still report days remaining, and it will label the cap comparison lifetime-unknown rather than producing a confident number from an assumption.

Declare the rotation interval and compare periods

--rotation-days 365 is the runbook, not a guess. The script reports rotation-outlives-token when the interval is longer than the granted lifetime, which is the recurring failure, separately from this-cycle-expires-first, which is a one-off you can fix by rotating early.

Declare the cap if somebody has told you what it is

There is no documented endpoint that returns an organization's maximum-lifetime setting, so --org-max-days 90 is a number a person gave you and the script treats it as declared rather than observed. With it, the script says whether the token you are holding is already over the ceiling — which means it is being blocked right now — or comfortably inside it.

Probe the organization, and read the fleet if you can

--org acme takes one reading against the organization so the report contains the shape rather than a theory: authenticating globally and refused in one namespace. Three causes produce that shape and the script names all three with their notes rather than claiming this one. Separately, --org-grants reads GET /orgs/{org}/personal-access-tokens, which lists every fine-grained token with access to the organization and when each dies. That endpoint is usable only by a GitHub App with the organization's personal-access-token permission, so it is optional and its absence is reported honestly.

How to check it worked

Once the rotation interval fits inside the cap, the same run reports the periods as compatible and stops reporting a recurring failure.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_token_lifetime.py \
    --issued 2026-03-01 --rotation-days 365 --org-max-days 90 --org acme
# read cost: 2 REST request(s), 0 of which count against the core quota
# credential: fine-grained PAT. The maximum-lifetime policy applies to this class.
# expiry header: 2027-02-25 12:00:00 UTC (362 day(s) remaining)
# granted lifetime: 366 day(s), from the issue date you supplied
# over-org-cap: the granted lifetime is longer than the declared cap of 90
#   day(s), so this token is blocked at acme while it works everywhere else.
#   The policy does not shorten a token; it refuses it.
# rotation-outlives-token: you rotate every 365 day(s) and no token here may
#   live longer than 90. This breaks once per cycle, forever, and rotating
#   earlier this once will not change that.
# org probe: refused-by-one-org — authenticates globally, refused at acme.
#   Three causes produce this shape and each has its own note.
# repair: shorten the rotation interval to under 90 days and alert on the
#   expiry header rather than a calendar, or move this job to a GitHub App
#   whose installation tokens are minted hourly. Nothing here rotates.

The full code

One free call, and optionally two cheap ones. Everything that decides anything is arithmetic on two dates and two intervals, which is exactly the sort of thing that should be pure and tested: the boundary between "expires before the next rotation" and "the schedule can never work" is one comparison, and getting it backwards produces a report that is confidently wrong in the reassuring direction.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 101 GitHub API fixes, free and open source.
github_token_lifetime.py
"""Compare a token's granted lifetime against the interval you rotate on.

Read only. One free call plus two optional cheap ones. Nothing is minted,
rotated or revoked: the repair is a schedule change and a policy only an
organization owner can alter, and both are printed rather than performed.

The point of the note: an organization can cap how long a fine-grained token
may live, and the failure that produces is not an expiry. The policy blocks a
non-compliant token from that organization while it keeps working everywhere
else, so every global check on the credential comes back clean.

What this can and cannot see: the expiry is a header on any authenticated
response. The issue date is not on the wire at all and has to be supplied. The
organization's maximum-lifetime setting has no documented endpoint, so it is a
declared number here and is labelled as one.

Environment:

    GITHUB_TOKEN    the credential whose lifetime is in question
"""
import argparse
import calendar
import json
import logging
import os
import sys
import time

import requests

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

API = "https://api.github.com"
UA = "github-token-lifetime/1.0"
HEADER = "github-authentication-token-expiration"
DAY = 86400.0

# The documented default ceiling for a fine-grained personal access token. A
# policy can be shorter; nothing readable through the API says what it is.
DEFAULT_FINE_GRAINED_MAX_DAYS = 366

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"),
)


def read_cost(with_org, with_grants):
    """(requests, quota units) this run will spend. Pure.

    Two numbers because they are not the same budget and the difference is the
    reason the probe is /rate_limit: that call reports the quota rather than
    consuming it, so a lifetime check can run every hour and cost nothing.
    """
    requests_made = 1 + (1 if with_org else 0) + (1 if with_grants else 0)
    return (requests_made, requests_made - 1)


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 policy_applies(kind):
    """Does a maximum-lifetime policy govern this class. Pure. (state, detail)."""
    if kind == "fine-grained PAT":
        return ("policy-applies",
                "the maximum-lifetime policy applies to this class. The "
                "documented default ceiling is %d days and an organization or "
                "enterprise can set something much shorter."
                % DEFAULT_FINE_GRAINED_MAX_DAYS)
    if kind == "classic PAT":
        return ("different-class",
                "classic tokens have no expiry requirement, so a "
                "maximum-lifetime policy does not cover them. An organization "
                "restricts them by blocking classic access altogether, which "
                "is a different refusal, and a classic token that dies after a "
                "long silence is the auto-revocation note.")
    if kind in ("App installation token", "App refresh token"):
        return ("minted-hourly",
                "installation tokens live about an hour and are minted on "
                "demand, so there is no lifetime for a policy to cap and no "
                "rotation for a runbook to schedule.")
    if kind in ("OAuth user token", "App user-to-server token"):
        return ("different-model",
                "this credential's life is governed by its authorization and "
                "refresh flow rather than by a token lifetime policy.")
    return ("class-unknown",
            "the credential class could not be named from its prefix, so "
            "whether the policy applies is unknown.")


def parse_stamp(value):
    """Epoch seconds from a timestamp, or None. Pure. No regular expression.

    The documented header shape is "2026-09-30 12:00:00 UTC"; an ISO instant
    with a Z turns up too. Anything else returns None rather than a plausible
    wrong date, because a wrong lifetime is worse than an unknown one.
    """
    if not isinstance(value, str):
        return None
    text = value.strip()
    if not text:
        return None
    upper = text.upper()
    if upper.endswith(" UTC") or upper.endswith(" GMT"):
        text = text[:-4].strip()
    elif upper.endswith("Z"):
        text = text[:-1].strip()
    text = text.replace("T", " ")
    date_part, _, time_part = text.partition(" ")
    bits = date_part.split("-")
    if len(bits) != 3 or not all(b.isdigit() for b in bits):
        return None
    hour, minute, second = 0, 0, 0
    if time_part.strip():
        clock = time_part.strip().split(":")
        if not all(c.split(".")[0].isdigit() for c in clock):
            return None
        parts = [int(c.split(".")[0]) for c in clock]
        while len(parts) < 3:
            parts.append(0)
        hour, minute, second = parts[0], parts[1], parts[2]
    try:
        return float(calendar.timegm((int(bits[0]), int(bits[1]), int(bits[2]),
                                      hour, minute, second, 0, 0, 0)))
    except (ValueError, OverflowError):
        return None


def header_value(headers, name=HEADER):
    """Case-insensitive header read against a plain dict. Pure."""
    if not isinstance(headers, dict):
        return None
    wanted = str(name).lower()
    for key, value in headers.items():
        if str(key).lower() == wanted:
            return value
    return None


def days_between(earlier, later):
    """Whole-ish days between two epochs, or None. Pure."""
    if earlier is None or later is None:
        return None
    return (later - earlier) / DAY


def granted_lifetime_days(issued_epoch, expires_epoch):
    """The life this token was actually given, or None. Pure.

    None is the common answer and it is an honest one: the issue date is not on
    the wire, so without it a token with 40 days left is indistinguishable from
    a short one at its start and a long one near its end.
    """
    span = days_between(issued_epoch, expires_epoch)
    if span is None or span <= 0:
        return None
    return span


def cap_verdict(granted_days, org_max_days):
    """Is the granted lifetime over the declared cap. Pure. (state, detail)."""
    if org_max_days is None:
        return ("cap-not-declared",
                "no maximum was declared. There is no documented endpoint that "
                "returns an organization's maximum-lifetime setting, so this "
                "number has to come from a person.")
    if granted_days is None:
        return ("lifetime-unknown",
                "the granted lifetime is unknown without an issue date, so it "
                "cannot be compared against the cap.")
    if granted_days > org_max_days:
        return ("over-org-cap",
                "the granted lifetime is %d day(s), longer than the declared "
                "cap of %d. A token over the cap is blocked at that "
                "organization while it keeps working everywhere else: the "
                "policy refuses tokens, it does not shorten them."
                % (round(granted_days), org_max_days))
    return ("within-org-cap",
            "the granted lifetime of %d day(s) is inside the declared cap of "
            "%d." % (round(granted_days), org_max_days))


def rotation_fit(granted_days, remaining_days, rotation_days):
    """Compare two periods, not a date and today. Pure. (state, detail).

    The distinction the note exists for. One of these states is a calendar
    entry; the other is a schedule that cannot work and will fail once per
    cycle until somebody changes it.
    """
    if rotation_days is None:
        return ("rotation-not-declared",
                "no rotation interval was declared, so there is nothing to "
                "compare a lifetime against.")
    if remaining_days is not None and remaining_days < 0:
        return ("already-expired",
                "the expiry is in the past. That is the ordinary expiry note, "
                "not a policy problem.")
    if granted_days is not None and rotation_days > granted_days:
        return ("rotation-outlives-token",
                "you rotate every %d day(s) and this token was granted %d. "
                "That breaks once per cycle, forever, and rotating earlier "
                "this once will not change it."
                % (rotation_days, round(granted_days)))
    if remaining_days is not None and rotation_days > remaining_days:
        return ("this-cycle-expires-first",
                "this token dies in %d day(s) and the next scheduled rotation "
                "is %d away. A one-off: rotate early and the schedule is still "
                "sound." % (round(remaining_days), rotation_days))
    if granted_days is None:
        return ("lifetime-unknown",
                "days remaining are known and the granted lifetime is not, so "
                "whether the schedule works in general cannot be decided from "
                "this reading.")
    return ("fits",
            "the rotation interval is inside the granted lifetime, so the "
            "schedule works on its own terms.")


def expiry_absent_meaning(kind):
    """What a missing expiry header means for this class. Pure. (state, detail)."""
    if kind == "classic PAT":
        return ("no-expiry-on-this-class",
                "a classic token with no expiry emits no header. That is not "
                "reassurance: a credential that never expires is a larger "
                "exposure than one that does, and it has its own note.")
    if kind in ("App installation token", "App refresh token"):
        return ("short-lived-by-design",
                "this class is minted for about an hour, so an absent header "
                "is the expected state and nothing here needs an alarm.")
    return ("expiry-not-reported",
            "no expiry header came back for a class that usually carries one. "
            "Either the response was not authenticated or this credential has "
            "no expiry at all; check which before concluding anything.")


def org_probe_verdict(self_status, org_status):
    """The shape of a policy block, without claiming it. Pure. (state, detail)."""
    mine = int(self_status or 0)
    theirs = None if org_status is None else int(org_status)
    if mine not in (200, 204):
        return ("credential-dead",
                "the credential did not authenticate at all, so nothing here "
                "is about one organization's policy.")
    if theirs is None:
        return ("org-not-probed",
                "no organization was probed, so the reading is about the "
                "credential in general rather than about one namespace.")
    if theirs in (200, 204):
        return ("org-reachable",
                "the organization answered, so nothing is blocking this "
                "credential there right now.")
    if theirs in (401, 403, 404):
        return ("refused-by-one-org",
                "the credential authenticates globally and is refused at this "
                "organization. Three things produce that shape: a token over a "
                "lifetime policy, a fine-grained token still waiting for owner "
                "approval, and a SAML authorization that has lapsed. Each has "
                "its own note; this reading narrows the search rather than "
                "ending it.")
    return ("org-probe-inconclusive",
            "HTTP %s from the organization is not a refusal or a success, so "
            "it says nothing about policy." % org_status)


def grants_over_cap(grants, org_max_days, now_epoch):
    """Which fine-grained tokens reaching the org die when. Pure. list.

    Fed by the App-only organization endpoint. Sorted soonest-first because the
    useful question is which credential goes next, not how many there are.
    """
    out = []
    for grant in grants or []:
        if not isinstance(grant, dict):
            continue
        owner = (grant.get("owner") or {}).get("login")
        expires = parse_stamp(grant.get("token_expires_at"))
        remaining = days_between(now_epoch, expires) if expires else None
        out.append({
            "owner": owner,
            "token_expires_at": grant.get("token_expires_at"),
            "expired": bool(grant.get("token_expired")),
            "days_remaining": None if remaining is None else round(remaining, 1),
            "no_expiry": grant.get("token_expires_at") is None,
            "over_declared_cap": (org_max_days is not None
                                  and grant.get("token_expires_at") is None),
        })
    out.sort(key=lambda row: (row["days_remaining"] is None,
                              row["days_remaining"] if row["days_remaining"] is not None else 0))
    return out


def verdict(cap_state, fit_state, applies_state):
    """The finding, in one state. Pure. (state, detail)."""
    if applies_state in ("different-class", "minted-hourly", "different-model"):
        return (applies_state,
                "a maximum-lifetime policy does not govern this credential "
                "class, so this note is not about your problem.")
    if cap_state == "over-org-cap":
        return ("blocked-by-lifetime-policy",
                "this token is longer-lived than the declared cap, which is "
                "the state that gets refused at that organization while every "
                "global check on the credential passes.")
    if fit_state == "rotation-outlives-token":
        return ("schedule-cannot-work",
                "the rotation interval is longer than any lifetime available "
                "here. This is a process finding, not an incident, and it will "
                "produce an outage every cycle until the schedule changes.")
    if fit_state == "this-cycle-expires-first":
        return ("rotate-early-this-once",
                "this particular token dies before the next scheduled "
                "rotation. Bring the rotation forward; the schedule itself is "
                "sound.")
    if fit_state == "already-expired":
        return ("expired",
                "the expiry has passed, which is the plain expiry case and has "
                "its own note.")
    if "unknown" in cap_state or "unknown" in fit_state:
        return ("lifetime-unknown",
                "not enough was supplied to compare periods. The issue date "
                "and the rotation interval are both facts only you hold.")
    return ("within-policy",
            "the granted lifetime is inside the declared cap and the rotation "
            "interval is inside the lifetime.")


def repair(state, rotation_days, org_max_days):
    """The sentence a reader has to act on. Pure. Nothing here rotates."""
    if state in ("blocked-by-lifetime-policy", "schedule-cannot-work"):
        cap = org_max_days if org_max_days is not None else "the enforced maximum"
        return ("shorten the rotation interval to fit inside %s day(s) and "
                "alert on the expiry header rather than on a calendar. Where "
                "that cadence is impractical, move this job to a GitHub App "
                "whose installation tokens are minted hourly and need no "
                "rotation at all. Nothing here rotates anything."
                % cap)
    if state == "rotate-early-this-once":
        return ("bring this rotation forward: the token dies before the next "
                "scheduled one. The interval of %s day(s) is otherwise fine."
                % rotation_days)
    if state == "expired":
        return ("mint a replacement. This is the plain expiry case and the "
                "policy comparison is not what failed.")
    if state == "lifetime-unknown":
        return ("supply the issue date recorded when this token was minted and "
                "the rotation interval from the runbook, then re-run. Neither "
                "is on the wire.")
    if state in ("different-class", "minted-hourly", "different-model"):
        return ("no action from this note; the credential class is not the one "
                "a lifetime policy governs.")
    return "nothing to repair from this reading."


def get(session, path):
    """One GET. Returns the response object."""
    return session.get(API + path, timeout=30)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--issued", help="YYYY-MM-DD the token was minted")
    parser.add_argument("--rotation-days", type=int,
                        help="how often the runbook says to rotate")
    parser.add_argument("--org-max-days", type=int,
                        help="the maximum lifetime somebody told you the org "
                             "enforces; not readable through the API")
    parser.add_argument("--org", help="an organization to probe")
    parser.add_argument("--org-grants", action="store_true",
                        help="list the org's fine-grained grants; needs a "
                             "GitHub App token with that permission")
    args = parser.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (the credential whose lifetime is in question)")
        return 2

    made, spent = read_cost(bool(args.org), bool(args.org_grants))
    log.info("read cost: %d REST request(s), %d of which count against the "
             "core quota", made, spent)

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        # GitHub refuses requests with no User-Agent before it looks at auth.
        "User-Agent": UA,
    })

    kind = token_kind(token)
    applies_state, applies_detail = policy_applies(kind)
    log.info("credential: %s. %s", kind, applies_detail)

    probe = get(session, "/rate_limit")
    raw_expiry = header_value(dict(probe.headers))
    now = time.time()
    expires_epoch = parse_stamp(raw_expiry)
    remaining = days_between(now, expires_epoch)
    if raw_expiry:
        log.info("expiry header: %s (%s day(s) remaining)", raw_expiry,
                 "unknown" if remaining is None else round(remaining))
    else:
        absent_state, absent_detail = expiry_absent_meaning(kind)
        log.info("%s: %s", absent_state, absent_detail)

    issued_epoch = parse_stamp(args.issued) if args.issued else None
    granted = granted_lifetime_days(issued_epoch, expires_epoch)
    if granted is not None:
        log.info("granted lifetime: %d day(s), from the issue date you supplied",
                 round(granted))

    cap_state, cap_detail = cap_verdict(granted, args.org_max_days)
    log.info("%s: %s", cap_state, cap_detail)

    fit_state, fit_detail = rotation_fit(granted, remaining, args.rotation_days)
    log.info("%s: %s", fit_state, fit_detail)

    org_status = None
    if args.org:
        org_probe = get(session, "/orgs/%s/repos?per_page=1" % args.org)
        org_status = org_probe.status_code
    shape_state, shape_detail = org_probe_verdict(probe.status_code, org_status)
    log.info("org probe: %s — %s", shape_state, shape_detail)

    grants = []
    if args.org_grants and args.org:
        listing = get(session, "/orgs/%s/personal-access-tokens?per_page=100" % args.org)
        if listing.status_code == 200:
            grants = grants_over_cap(listing.json(), args.org_max_days, now)
            log.info("org grants: %d fine-grained token(s) reach %s", len(grants),
                     args.org)
        else:
            log.info("org grants unreadable (HTTP %s). That endpoint is usable "
                     "only by a GitHub App with the organization's personal "
                     "access token permission.", listing.status_code)

    state, detail = verdict(cap_state, fit_state, applies_state)
    log.info("%s: %s", state, detail)
    fix = repair(state, args.rotation_days, args.org_max_days)
    log.info("repair: %s", fix)

    print(json.dumps({
        "token_kind": kind,
        "policy_applies": applies_state,
        "expiry_header": raw_expiry,
        "days_remaining": None if remaining is None else round(remaining, 1),
        "granted_lifetime_days": None if granted is None else round(granted, 1),
        "declared_org_max_days": args.org_max_days,
        "declared_rotation_days": args.rotation_days,
        "cap_state": cap_state,
        "rotation_state": fit_state,
        "org_probe_state": shape_state,
        "org_grants": grants[:20],
        "state": state,
        "detail": detail,
        "repair": fix,
    }, indent=2, default=str))
    return 1 if state in ("blocked-by-lifetime-policy", "schedule-cannot-work",
                          "rotate-early-this-once", "expired") else 0


if __name__ == "__main__":
    sys.exit(main())
github-token-lifetime.mjs
/**
 * Compare a token's granted lifetime against the interval you rotate on.
 *
 * Read only. One free call plus two optional cheap ones. Nothing is minted,
 * rotated or revoked: the repair is a schedule change and a policy only an
 * organization owner can alter, and both are printed rather than performed.
 *
 * An organization can cap how long a fine-grained token may live. The failure
 * that produces is not an expiry: the policy blocks a non-compliant token at
 * that organization while it keeps working everywhere else.
 *
 * Environment:
 *   GITHUB_TOKEN         the credential whose lifetime is in question
 *   GITHUB_ISSUED        optional YYYY-MM-DD the token was minted
 *   GITHUB_ROTATION_DAYS optional rotation interval from the runbook
 *   GITHUB_ORG_MAX_DAYS  optional declared cap; not readable through the API
 *   GITHUB_ORG           optional organization to probe
 *   GITHUB_ORG_GRANTS    set to 1 to list the org's fine-grained grants
 */
const API = 'https://api.github.com';
const UA = 'github-token-lifetime/1.0';

export const HEADER = 'github-authentication-token-expiration';
export const DAY = 86400;

/** The documented default ceiling for a fine-grained token. A policy can be shorter. */
export const DEFAULT_FINE_GRAINED_MAX_DAYS = 366;

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'],
];

/** [requests, quota units] this run will spend. Pure. */
export function readCost(withOrg, withGrants) {
  const made = 1 + (withOrg ? 1 : 0) + (withGrants ? 1 : 0);
  return [made, made - 1];
}

/** 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';
}

/** Does a maximum-lifetime policy govern this class. Pure. [state, detail]. */
export function policyApplies(kind) {
  if (kind === 'fine-grained PAT') {
    return ['policy-applies', 'the maximum-lifetime policy applies to this '
      + `class. The documented default ceiling is ${DEFAULT_FINE_GRAINED_MAX_DAYS} `
      + 'days and an organization or enterprise can set something much shorter.'];
  }
  if (kind === 'classic PAT') {
    return ['different-class', 'classic tokens have no expiry requirement, so a '
      + 'maximum-lifetime policy does not cover them. An organization restricts '
      + 'them by blocking classic access altogether, which is a different '
      + 'refusal, and a classic token that dies after a long silence is the '
      + 'auto-revocation note.'];
  }
  if (kind === 'App installation token' || kind === 'App refresh token') {
    return ['minted-hourly', 'installation tokens live about an hour and are '
      + 'minted on demand, so there is no lifetime for a policy to cap and no '
      + 'rotation for a runbook to schedule.'];
  }
  if (kind === 'OAuth user token' || kind === 'App user-to-server token') {
    return ['different-model', "this credential's life is governed by its "
      + 'authorization and refresh flow rather than by a token lifetime policy.'];
  }
  return ['class-unknown', 'the credential class could not be named from its '
    + 'prefix, so whether the policy applies is unknown.'];
}

/** Epoch seconds from a timestamp, or null. Pure. No regular expression. */
export function parseStamp(value) {
  if (typeof value !== 'string') return null;
  let text = value.trim();
  if (!text) return null;
  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();
  text = text.split('T').join(' ');
  const cut = text.indexOf(' ');
  const datePart = cut === -1 ? text : text.slice(0, cut);
  const timePart = cut === -1 ? '' : text.slice(cut + 1).trim();
  const bits = datePart.split('-');
  const digits = (s) => s.length > 0 && [...s].every((c) => c >= '0' && c <= '9');
  if (bits.length !== 3 || !bits.every(digits)) return null;
  let hour = 0;
  let minute = 0;
  let second = 0;
  if (timePart) {
    const clock = timePart.split(':').map((c) => c.split('.')[0]);
    if (!clock.every(digits)) return null;
    [hour = 0, minute = 0, second = 0] = clock.map(Number);
  }
  const ms = Date.UTC(Number(bits[0]), Number(bits[1]) - 1, Number(bits[2]),
    hour, minute, second);
  return Number.isNaN(ms) ? null : ms / 1000;
}

/** Case-insensitive header read against a plain object. Pure. */
export function headerValue(headers, name = HEADER) {
  if (!headers || typeof headers !== 'object') return null;
  const wanted = String(name).toLowerCase();
  for (const key of Object.keys(headers)) {
    if (key.toLowerCase() === wanted) return headers[key];
  }
  return null;
}

/** Days between two epochs, or null. Pure. */
export function daysBetween(earlier, later) {
  if (earlier === null || earlier === undefined) return null;
  if (later === null || later === undefined) return null;
  return (later - earlier) / DAY;
}

/** The life this token was actually given, or null. Pure. */
export function grantedLifetimeDays(issuedEpoch, expiresEpoch) {
  const span = daysBetween(issuedEpoch, expiresEpoch);
  if (span === null || span <= 0) return null;
  return span;
}

/** Is the granted lifetime over the declared cap. Pure. [state, detail]. */
export function capVerdict(grantedDays, orgMaxDays) {
  if (orgMaxDays === null || orgMaxDays === undefined) {
    return ['cap-not-declared', 'no maximum was declared. There is no '
      + "documented endpoint that returns an organization's maximum-lifetime "
      + 'setting, so this number has to come from a person.'];
  }
  if (grantedDays === null || grantedDays === undefined) {
    return ['lifetime-unknown', 'the granted lifetime is unknown without an '
      + 'issue date, so it cannot be compared against the cap.'];
  }
  if (grantedDays > orgMaxDays) {
    return ['over-org-cap', `the granted lifetime is ${Math.round(grantedDays)} `
      + `day(s), longer than the declared cap of ${orgMaxDays}. A token over the `
      + 'cap is blocked at that organization while it keeps working everywhere '
      + 'else: the policy refuses tokens, it does not shorten them.'];
  }
  return ['within-org-cap', `the granted lifetime of ${Math.round(grantedDays)} `
    + `day(s) is inside the declared cap of ${orgMaxDays}.`];
}

/** Compare two periods, not a date and today. Pure. [state, detail]. */
export function rotationFit(grantedDays, remainingDays, rotationDays) {
  if (rotationDays === null || rotationDays === undefined) {
    return ['rotation-not-declared', 'no rotation interval was declared, so '
      + 'there is nothing to compare a lifetime against.'];
  }
  if (remainingDays !== null && remainingDays !== undefined && remainingDays < 0) {
    return ['already-expired', 'the expiry is in the past. That is the ordinary '
      + 'expiry note, not a policy problem.'];
  }
  if (grantedDays !== null && grantedDays !== undefined && rotationDays > grantedDays) {
    return ['rotation-outlives-token', `you rotate every ${rotationDays} day(s) `
      + `and this token was granted ${Math.round(grantedDays)}. That breaks once `
      + 'per cycle, forever, and rotating earlier this once will not change it.'];
  }
  if (remainingDays !== null && remainingDays !== undefined && rotationDays > remainingDays) {
    return ['this-cycle-expires-first', `this token dies in `
      + `${Math.round(remainingDays)} day(s) and the next scheduled rotation is `
      + `${rotationDays} away. A one-off: rotate early and the schedule is still sound.`];
  }
  if (grantedDays === null || grantedDays === undefined) {
    return ['lifetime-unknown', 'days remaining are known and the granted '
      + 'lifetime is not, so whether the schedule works in general cannot be '
      + 'decided from this reading.'];
  }
  return ['fits', 'the rotation interval is inside the granted lifetime, so the '
    + 'schedule works on its own terms.'];
}

/** What a missing expiry header means for this class. Pure. [state, detail]. */
export function expiryAbsentMeaning(kind) {
  if (kind === 'classic PAT') {
    return ['no-expiry-on-this-class', 'a classic token with no expiry emits no '
      + 'header. That is not reassurance: a credential that never expires is a '
      + 'larger exposure than one that does, and it has its own note.'];
  }
  if (kind === 'App installation token' || kind === 'App refresh token') {
    return ['short-lived-by-design', 'this class is minted for about an hour, so '
      + 'an absent header is the expected state and nothing here needs an alarm.'];
  }
  return ['expiry-not-reported', 'no expiry header came back for a class that '
    + 'usually carries one. Either the response was not authenticated or this '
    + 'credential has no expiry at all; check which before concluding anything.'];
}

/** The shape of a policy block, without claiming it. Pure. [state, detail]. */
export function orgProbeVerdict(selfStatus, orgStatus) {
  const mine = Number(selfStatus) || 0;
  const theirs = (orgStatus === null || orgStatus === undefined) ? null : Number(orgStatus);
  if (![200, 204].includes(mine)) {
    return ['credential-dead', 'the credential did not authenticate at all, so '
      + "nothing here is about one organization's policy."];
  }
  if (theirs === null) {
    return ['org-not-probed', 'no organization was probed, so the reading is '
      + 'about the credential in general rather than about one namespace.'];
  }
  if ([200, 204].includes(theirs)) {
    return ['org-reachable', 'the organization answered, so nothing is blocking '
      + 'this credential there right now.'];
  }
  if ([401, 403, 404].includes(theirs)) {
    return ['refused-by-one-org', 'the credential authenticates globally and is '
      + 'refused at this organization. Three things produce that shape: a token '
      + 'over a lifetime policy, a fine-grained token still waiting for owner '
      + 'approval, and a SAML authorization that has lapsed. Each has its own '
      + 'note; this reading narrows the search rather than ending it.'];
  }
  return ['org-probe-inconclusive', `HTTP ${orgStatus} from the organization is `
    + 'not a refusal or a success, so it says nothing about policy.'];
}

/** Which fine-grained tokens reaching the org die when. Pure. */
export function grantsOverCap(grants, orgMaxDays, nowEpoch) {
  const out = [];
  for (const grant of grants || []) {
    if (!grant || typeof grant !== 'object') continue;
    const owner = (grant.owner && grant.owner.login) || null;
    const expires = grant.token_expires_at ? parseStamp(grant.token_expires_at) : null;
    const remaining = expires ? daysBetween(nowEpoch, expires) : null;
    out.push({
      owner,
      token_expires_at: grant.token_expires_at ?? null,
      expired: Boolean(grant.token_expired),
      days_remaining: remaining === null ? null : Math.round(remaining * 10) / 10,
      no_expiry: (grant.token_expires_at ?? null) === null,
      over_declared_cap: (orgMaxDays !== null && orgMaxDays !== undefined
        && (grant.token_expires_at ?? null) === null),
    });
  }
  out.sort((a, b) => {
    if (a.days_remaining === null) return 1;
    if (b.days_remaining === null) return -1;
    return a.days_remaining - b.days_remaining;
  });
  return out;
}

/** The finding, in one state. Pure. [state, detail]. */
export function verdict(capState, fitState, appliesState) {
  if (['different-class', 'minted-hourly', 'different-model'].includes(appliesState)) {
    return [appliesState, 'a maximum-lifetime policy does not govern this '
      + 'credential class, so this note is not about your problem.'];
  }
  if (capState === 'over-org-cap') {
    return ['blocked-by-lifetime-policy', 'this token is longer-lived than the '
      + 'declared cap, which is the state that gets refused at that '
      + 'organization while every global check on the credential passes.'];
  }
  if (fitState === 'rotation-outlives-token') {
    return ['schedule-cannot-work', 'the rotation interval is longer than any '
      + 'lifetime available here. This is a process finding, not an incident, '
      + 'and it will produce an outage every cycle until the schedule changes.'];
  }
  if (fitState === 'this-cycle-expires-first') {
    return ['rotate-early-this-once', 'this particular token dies before the '
      + 'next scheduled rotation. Bring the rotation forward; the schedule '
      + 'itself is sound.'];
  }
  if (fitState === 'already-expired') {
    return ['expired', 'the expiry has passed, which is the plain expiry case '
      + 'and has its own note.'];
  }
  if (capState.includes('unknown') || fitState.includes('unknown')) {
    return ['lifetime-unknown', 'not enough was supplied to compare periods. '
      + 'The issue date and the rotation interval are both facts only you hold.'];
  }
  return ['within-policy', 'the granted lifetime is inside the declared cap and '
    + 'the rotation interval is inside the lifetime.'];
}

/** The sentence a reader has to act on. Pure. Nothing here rotates. */
export function repair(state, rotationDays, orgMaxDays) {
  if (['blocked-by-lifetime-policy', 'schedule-cannot-work'].includes(state)) {
    const cap = (orgMaxDays === null || orgMaxDays === undefined)
      ? 'the enforced maximum' : orgMaxDays;
    return `shorten the rotation interval to fit inside ${cap} day(s) and alert `
      + 'on the expiry header rather than on a calendar. Where that cadence is '
      + 'impractical, move this job to a GitHub App whose installation tokens '
      + 'are minted hourly and need no rotation at all. Nothing here rotates '
      + 'anything.';
  }
  if (state === 'rotate-early-this-once') {
    return 'bring this rotation forward: the token dies before the next '
      + `scheduled one. The interval of ${rotationDays} day(s) is otherwise fine.`;
  }
  if (state === 'expired') {
    return 'mint a replacement. This is the plain expiry case and the policy '
      + 'comparison is not what failed.';
  }
  if (state === 'lifetime-unknown') {
    return 'supply the issue date recorded when this token was minted and the '
      + 'rotation interval from the runbook, then re-run. Neither is on the wire.';
  }
  if (['different-class', 'minted-hourly', 'different-model'].includes(state)) {
    return 'no action from this note; the credential class is not the one a '
      + 'lifetime policy governs.';
  }
  return 'nothing to repair from this reading.';
}

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;
  if (!token) {
    console.error('set GITHUB_TOKEN (the credential whose lifetime is in question)');
    process.exitCode = 2;
    return;
  }
  const org = process.env.GITHUB_ORG || '';
  const withGrants = process.env.GITHUB_ORG_GRANTS === '1';
  const rotationDays = process.env.GITHUB_ROTATION_DAYS
    ? Number(process.env.GITHUB_ROTATION_DAYS) : null;
  const orgMaxDays = process.env.GITHUB_ORG_MAX_DAYS
    ? Number(process.env.GITHUB_ORG_MAX_DAYS) : null;
  const [made, spent] = readCost(Boolean(org), withGrants);
  console.log(`read cost: ${made} REST request(s), ${spent} of which count `
    + 'against the core quota');

  const kind = tokenKind(token);
  const [appliesState, appliesDetail] = policyApplies(kind);
  console.log(`credential: ${kind}. ${appliesDetail}`);

  const probe = await fetch(`${API}/rate_limit`, { headers: headers(token) });
  const headerBag = {};
  probe.headers.forEach((value, key) => { headerBag[key] = value; });
  const rawExpiry = headerValue(headerBag);
  const now = Date.now() / 1000;
  const expiresEpoch = parseStamp(rawExpiry);
  const remaining = daysBetween(now, expiresEpoch);
  if (rawExpiry) {
    console.log(`expiry header: ${rawExpiry} (`
      + `${remaining === null ? 'unknown' : Math.round(remaining)} day(s) remaining)`);
  } else {
    const [absentState, absentDetail] = expiryAbsentMeaning(kind);
    console.log(`${absentState}: ${absentDetail}`);
  }

  const issuedEpoch = process.env.GITHUB_ISSUED
    ? parseStamp(process.env.GITHUB_ISSUED) : null;
  const granted = grantedLifetimeDays(issuedEpoch, expiresEpoch);
  if (granted !== null) {
    console.log(`granted lifetime: ${Math.round(granted)} day(s), from the issue `
      + 'date you supplied');
  }

  const [capState, capDetail] = capVerdict(granted, orgMaxDays);
  console.log(`${capState}: ${capDetail}`);
  const [fitState, fitDetail] = rotationFit(granted, remaining, rotationDays);
  console.log(`${fitState}: ${fitDetail}`);

  let orgStatus = null;
  if (org) {
    const orgProbe = await fetch(`${API}/orgs/${org}/repos?per_page=1`,
      { headers: headers(token) });
    orgStatus = orgProbe.status;
  }
  const [shapeState, shapeDetail] = orgProbeVerdict(probe.status, orgStatus);
  console.log(`org probe: ${shapeState} - ${shapeDetail}`);

  let grants = [];
  if (withGrants && org) {
    const listing = await fetch(
      `${API}/orgs/${org}/personal-access-tokens?per_page=100`,
      { headers: headers(token) },
    );
    if (listing.status === 200) {
      grants = grantsOverCap(await listing.json(), orgMaxDays, now);
      console.log(`org grants: ${grants.length} fine-grained token(s) reach ${org}`);
    } else {
      console.log(`org grants unreadable (HTTP ${listing.status}). That endpoint `
        + "is usable only by a GitHub App with the organization's personal "
        + 'access token permission.');
    }
  }

  const [state, detail] = verdict(capState, fitState, appliesState);
  console.log(`${state}: ${detail}`);
  const fix = repair(state, rotationDays, orgMaxDays);
  console.log(`repair: ${fix}`);

  console.log(JSON.stringify({
    token_kind: kind,
    policy_applies: appliesState,
    expiry_header: rawExpiry,
    days_remaining: remaining === null ? null : Math.round(remaining * 10) / 10,
    granted_lifetime_days: granted === null ? null : Math.round(granted * 10) / 10,
    declared_org_max_days: orgMaxDays,
    declared_rotation_days: rotationDays,
    cap_state: capState,
    rotation_state: fitState,
    org_probe_state: shapeState,
    org_grants: grants.slice(0, 20),
    state,
    detail,
    repair: fix,
  }, null, 2));
  process.exitCode = ['blocked-by-lifetime-policy', 'schedule-cannot-work',
    'rotate-early-this-once', 'expired'].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

Everything interesting here is arithmetic on dates, so the suite is fixed epochs rather than anything that moves. The first group is the parser, against the documented header shape and against the ISO instant that also turns up. The middle group is the distinction the note exists for: a token that dies before the next rotation, which is a calendar entry, against a rotation interval longer than any lifetime the policy permits, which is a schedule that can never work. The tokens in the fixtures are obviously fake and far too short to be real. The last group is the honest ones: an unknown issue date stays unknown, and an undeclared cap is not an absent one.

test_github_token_lifetime.py
from github_token_lifetime import (
    DEFAULT_FINE_GRAINED_MAX_DAYS, cap_verdict, days_between,
    expiry_absent_meaning, granted_lifetime_days, grants_over_cap,
    header_value, org_probe_verdict, parse_stamp, policy_applies, read_cost,
    repair, rotation_fit, token_kind, verdict,
)

# Obviously fake and far shorter than any real credential.
FINE = "github_pat_FAKE"
CLASSIC = "ghp_FAKE"
INSTALLATION = "ghs_FAKE"

NOW = 1_800_000_000.0
DAY = 86400.0


def test_the_documented_header_shape_parses_and_so_does_the_iso_one():
    assert parse_stamp("2026-09-30 12:00:00 UTC") == 1790769600.0
    assert parse_stamp("2026-09-30T12:00:00Z") == 1790769600.0
    assert parse_stamp("2026-09-30") == 1790726400.0
    assert parse_stamp("not a date") is None
    assert parse_stamp(None) is None
    # A shape it cannot read returns None rather than a plausible wrong date.
    assert parse_stamp("30/09/2026") is None


def test_the_header_is_read_case_insensitively():
    assert header_value({"Github-Authentication-Token-Expiration": "x"}) == "x"
    assert header_value({"unrelated": "y"}) is None
    assert header_value(None) is None


def test_the_granted_lifetime_needs_an_issue_date_and_says_so():
    granted = granted_lifetime_days(NOW - 30 * DAY, NOW + 60 * DAY)
    assert round(granted) == 90
    # Without an issue date the answer is unknown, not a guess.
    assert granted_lifetime_days(None, NOW + 60 * DAY) is None
    state, detail = cap_verdict(None, 90)
    assert state == "lifetime-unknown"
    assert "without an issue date" in detail


def test_a_token_over_the_cap_is_blocked_not_shortened():
    state, detail = cap_verdict(366, 90)
    assert state == "over-org-cap"
    assert "it does not shorten them" in detail
    assert cap_verdict(60, 90)[0] == "within-org-cap"


def test_an_undeclared_cap_is_not_an_absent_one():
    state, detail = cap_verdict(366, None)
    assert state == "cap-not-declared"
    assert "no documented endpoint" in detail


def test_the_schedule_that_can_never_work_is_kept_apart_from_the_one_off():
    # The recurring finding: the interval is longer than any lifetime allowed.
    state, detail = rotation_fit(90, 80, 365)
    assert state == "rotation-outlives-token"
    assert "once per cycle, forever" in detail
    # The one-off: this token dies first, and the schedule itself is fine.
    state, detail = rotation_fit(365, 20, 90)
    assert state == "this-cycle-expires-first"
    assert "A one-off" in detail
    assert rotation_fit(365, 300, 90)[0] == "fits"
    assert rotation_fit(365, -1, 90)[0] == "already-expired"
    assert rotation_fit(None, 300, None)[0] == "rotation-not-declared"


def test_the_two_findings_have_two_different_repairs():
    recurring = verdict("within-org-cap", "rotation-outlives-token", "policy-applies")
    assert recurring[0] == "schedule-cannot-work"
    assert "every cycle" in recurring[1]
    once = verdict("within-org-cap", "this-cycle-expires-first", "policy-applies")
    assert once[0] == "rotate-early-this-once"
    assert "Bring the rotation forward" in once[1]
    assert "GitHub App" in repair("schedule-cannot-work", 365, 90)
    assert "bring this rotation forward" in repair("rotate-early-this-once", 90, None)


def test_being_over_the_cap_outranks_the_schedule():
    # A token already over the cap is being refused now; the schedule is the
    # cause and the block is the symptom, so the block is reported first.
    state, _ = verdict("over-org-cap", "fits", "policy-applies")
    assert state == "blocked-by-lifetime-policy"


def test_the_policy_only_governs_one_credential_class():
    assert policy_applies("fine-grained PAT")[0] == "policy-applies"
    assert str(DEFAULT_FINE_GRAINED_MAX_DAYS) in policy_applies("fine-grained PAT")[1]
    state, detail = policy_applies("classic PAT")
    assert state == "different-class"
    assert "auto-revocation note" in detail
    assert policy_applies("App installation token")[0] == "minted-hourly"
    assert policy_applies("unknown")[0] == "class-unknown"
    assert token_kind(FINE) == "fine-grained PAT"
    assert token_kind(CLASSIC) == "classic PAT"
    assert token_kind(INSTALLATION) == "App installation token"
    assert token_kind("") == "unknown"


def test_a_wrong_class_ends_the_note_rather_than_grading_it():
    state, detail = verdict("cap-not-declared", "fits", "minted-hourly")
    assert state == "minted-hourly"
    assert "not about your problem" in detail
    assert "no action from this note" in repair("minted-hourly", None, None)


def test_the_missing_header_means_different_things_per_class():
    assert expiry_absent_meaning("classic PAT")[0] == "no-expiry-on-this-class"
    assert "larger exposure" in expiry_absent_meaning("classic PAT")[1]
    assert expiry_absent_meaning("App installation token")[0] == "short-lived-by-design"
    assert expiry_absent_meaning("fine-grained PAT")[0] == "expiry-not-reported"


def test_the_org_probe_reports_a_shape_and_names_its_rivals():
    state, detail = org_probe_verdict(200, 403)
    assert state == "refused-by-one-org"
    assert "Three things produce that shape" in detail
    assert "narrows the search rather than ending it" in detail
    assert org_probe_verdict(200, 200)[0] == "org-reachable"
    assert org_probe_verdict(401, 403)[0] == "credential-dead"
    assert org_probe_verdict(200, None)[0] == "org-not-probed"


def test_the_fleet_read_sorts_by_which_credential_goes_next():
    grants = [
        {"owner": {"login": "carol"}, "token_expires_at": "2026-12-01 00:00:00 UTC",
         "token_expired": False},
        {"owner": {"login": "alice"}, "token_expires_at": None, "token_expired": False},
        {"owner": {"login": "bob"}, "token_expires_at": "2026-06-01 00:00:00 UTC",
         "token_expired": False},
    ]
    rows = grants_over_cap(grants, 90, NOW)
    assert [r["owner"] for r in rows] == ["bob", "carol", "alice"]
    # A grant with no expiry at all cannot satisfy any maximum lifetime.
    assert rows[-1]["no_expiry"] is True
    assert rows[-1]["over_declared_cap"] is True
    assert grants_over_cap([], 90, NOW) == []


def test_the_free_probe_is_counted_as_free():
    assert read_cost(False, False) == (1, 0)
    assert read_cost(True, False) == (2, 1)
    assert read_cost(True, True) == (3, 2)


def test_days_between_is_signed_and_none_safe():
    assert days_between(NOW, NOW + DAY) == 1.0
    assert days_between(NOW, NOW - DAY) == -1.0
    assert days_between(None, NOW) is None
github-token-lifetime.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  DEFAULT_FINE_GRAINED_MAX_DAYS, capVerdict, daysBetween, expiryAbsentMeaning,
  grantedLifetimeDays, grantsOverCap, headerValue, orgProbeVerdict, parseStamp,
  policyApplies, readCost, repair, rotationFit, tokenKind, verdict,
} from './github-token-lifetime.mjs';

// Obviously fake and far shorter than any real credential.
const FINE = 'github_pat_FAKE';
const CLASSIC = 'ghp_FAKE';
const INSTALLATION = 'ghs_FAKE';

const NOW = 1800000000;
const DAY = 86400;

test('the documented header shape parses and so does the iso one', () => {
  assert.equal(parseStamp('2026-09-30 12:00:00 UTC'), 1790769600);
  assert.equal(parseStamp('2026-09-30T12:00:00Z'), 1790769600);
  assert.equal(parseStamp('2026-09-30'), 1790726400);
  assert.equal(parseStamp('not a date'), null);
  assert.equal(parseStamp(null), null);
  assert.equal(parseStamp('30/09/2026'), null);
});

test('the header is read case insensitively', () => {
  assert.equal(headerValue({ 'Github-Authentication-Token-Expiration': 'x' }), 'x');
  assert.equal(headerValue({ unrelated: 'y' }), null);
  assert.equal(headerValue(null), null);
});

test('the granted lifetime needs an issue date and says so', () => {
  assert.equal(Math.round(grantedLifetimeDays(NOW - 30 * DAY, NOW + 60 * DAY)), 90);
  assert.equal(grantedLifetimeDays(null, NOW + 60 * DAY), null);
  const [state, detail] = capVerdict(null, 90);
  assert.equal(state, 'lifetime-unknown');
  assert.match(detail, /without an issue date/);
});

test('a token over the cap is blocked not shortened', () => {
  const [state, detail] = capVerdict(366, 90);
  assert.equal(state, 'over-org-cap');
  assert.match(detail, /it does not shorten them/);
  assert.equal(capVerdict(60, 90)[0], 'within-org-cap');
});

test('an undeclared cap is not an absent one', () => {
  const [state, detail] = capVerdict(366, null);
  assert.equal(state, 'cap-not-declared');
  assert.match(detail, /no documented endpoint/);
});

test('the schedule that can never work is kept apart from the one off', () => {
  let [state, detail] = rotationFit(90, 80, 365);
  assert.equal(state, 'rotation-outlives-token');
  assert.match(detail, /once per cycle, forever/);
  [state, detail] = rotationFit(365, 20, 90);
  assert.equal(state, 'this-cycle-expires-first');
  assert.match(detail, /A one-off/);
  assert.equal(rotationFit(365, 300, 90)[0], 'fits');
  assert.equal(rotationFit(365, -1, 90)[0], 'already-expired');
  assert.equal(rotationFit(null, 300, null)[0], 'rotation-not-declared');
});

test('the two findings have two different repairs', () => {
  const recurring = verdict('within-org-cap', 'rotation-outlives-token', 'policy-applies');
  assert.equal(recurring[0], 'schedule-cannot-work');
  assert.match(recurring[1], /every cycle/);
  const once = verdict('within-org-cap', 'this-cycle-expires-first', 'policy-applies');
  assert.equal(once[0], 'rotate-early-this-once');
  assert.match(once[1], /Bring the rotation forward/);
  assert.match(repair('schedule-cannot-work', 365, 90), /GitHub App/);
  assert.match(repair('rotate-early-this-once', 90, null), /bring this rotation forward/);
});

test('being over the cap outranks the schedule', () => {
  assert.equal(verdict('over-org-cap', 'fits', 'policy-applies')[0], 'blocked-by-lifetime-policy');
});

test('the policy only governs one credential class', () => {
  assert.equal(policyApplies('fine-grained PAT')[0], 'policy-applies');
  assert.ok(policyApplies('fine-grained PAT')[1]
    .includes(String(DEFAULT_FINE_GRAINED_MAX_DAYS)));
  const [state, detail] = policyApplies('classic PAT');
  assert.equal(state, 'different-class');
  assert.match(detail, /auto-revocation note/);
  assert.equal(policyApplies('App installation token')[0], 'minted-hourly');
  assert.equal(policyApplies('unknown')[0], 'class-unknown');
  assert.equal(tokenKind(FINE), 'fine-grained PAT');
  assert.equal(tokenKind(CLASSIC), 'classic PAT');
  assert.equal(tokenKind(INSTALLATION), 'App installation token');
  assert.equal(tokenKind(''), 'unknown');
});

test('a wrong class ends the note rather than grading it', () => {
  const [state, detail] = verdict('cap-not-declared', 'fits', 'minted-hourly');
  assert.equal(state, 'minted-hourly');
  assert.match(detail, /not about your problem/);
  assert.match(repair('minted-hourly', null, null), /no action from this note/);
});

test('the missing header means different things per class', () => {
  assert.equal(expiryAbsentMeaning('classic PAT')[0], 'no-expiry-on-this-class');
  assert.match(expiryAbsentMeaning('classic PAT')[1], /larger exposure/);
  assert.equal(expiryAbsentMeaning('App installation token')[0], 'short-lived-by-design');
  assert.equal(expiryAbsentMeaning('fine-grained PAT')[0], 'expiry-not-reported');
});

test('the org probe reports a shape and names its rivals', () => {
  const [state, detail] = orgProbeVerdict(200, 403);
  assert.equal(state, 'refused-by-one-org');
  assert.match(detail, /Three things produce that shape/);
  assert.match(detail, /narrows the search rather than ending it/);
  assert.equal(orgProbeVerdict(200, 200)[0], 'org-reachable');
  assert.equal(orgProbeVerdict(401, 403)[0], 'credential-dead');
  assert.equal(orgProbeVerdict(200, null)[0], 'org-not-probed');
});

test('the fleet read sorts by which credential goes next', () => {
  const grants = [
    { owner: { login: 'carol' }, token_expires_at: '2026-12-01 00:00:00 UTC', token_expired: false },
    { owner: { login: 'alice' }, token_expires_at: null, token_expired: false },
    { owner: { login: 'bob' }, token_expires_at: '2026-06-01 00:00:00 UTC', token_expired: false },
  ];
  const rows = grantsOverCap(grants, 90, NOW);
  assert.deepEqual(rows.map((r) => r.owner), ['bob', 'carol', 'alice']);
  assert.equal(rows[rows.length - 1].no_expiry, true);
  assert.equal(rows[rows.length - 1].over_declared_cap, true);
  assert.deepEqual(grantsOverCap([], 90, NOW), []);
});

test('the free probe is counted as free', () => {
  assert.deepEqual(readCost(false, false), [1, 0]);
  assert.deepEqual(readCost(true, false), [2, 1]);
  assert.deepEqual(readCost(true, true), [3, 2]);
});

test('days between is signed and null safe', () => {
  assert.equal(daysBetween(NOW, NOW + DAY), 1);
  assert.equal(daysBetween(NOW, NOW - DAY), -1);
  assert.equal(daysBetween(null, NOW), null);
});

FAQ

Is this not just the token-expiry note with extra steps?

No, and the difference is which number you watch. That note counts down the days a token has left and alerts you at thirty; it is the right tool when a credential is heading for its own expiry. This one compares the lifetime you were <em>granted</em> against the interval you rotate on, which is a comparison between two periods. An expiry alert fires, somebody rotates, and if the cap is shorter than the cycle the next token has exactly the same problem. The countdown can never surface that, because nothing about it is ever surprising.

The token still authenticates. How can a lifetime policy be the cause?

Because the policy does not shorten or revoke anything. GitHub's documentation says non-compliant tokens are blocked from accessing the organization, so the credential stays alive and correct and is refused in one namespace. That is why every check anybody runs on it &mdash; <code>GET /user</code>, the personal account, the other organization &mdash; comes back clean, and why the investigation goes to scopes instead.

Can the script read the organization's maximum-lifetime setting?

Not through any documented endpoint, and pretending otherwise would be the worst thing this note could do. The cap is supplied as a declared number and labelled as declared throughout. What <em>is</em> readable, with a GitHub App holding the organization's personal-access-token permission, is <code>GET /orgs/{org}/personal-access-tokens</code>: every fine-grained token with access to the organization, with <code>token_expires_at</code> and <code>token_expired</code>. That gives you the fleet even though it does not give you the rule.

Our tokens are classic PATs. Does the cap apply to us?

Not this one. The maximum-lifetime policy is a fine-grained personal access token policy, and classic tokens have no expiry requirement to cap. An organization that wants to constrain classic tokens blocks their access to the organization outright, which produces a different refusal, and a classic token that quietly stops working after a long idle period is the auto-revocation case instead. The script names the class first for exactly this reason.

What if we genuinely cannot rotate every ninety days?

Then stop trying to make a human-shaped credential do a machine-shaped job. A GitHub App mints installation tokens that live about an hour and are fetched on demand, so there is no lifetime for a policy to cap and no rotation for anybody to schedule or forget. That is a bigger change than editing a runbook, which is why the script only prints it as the alternative when the cadence and the cap genuinely cannot be reconciled.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.