Skip to content

Diagnostic GitHub API

a GitHub App JWT that expires in an hour is refused

The private key is right, the App exists, the signature verifies and the request still comes back 401 {"message": "'Expiration time' claim ('exp') is too far in the future"}. Three people regenerate the key. Somebody rewrites the signing code in a different library. The defect is one number, chosen three lines above the request, and it was wrong before anything was sent.

Read-only token Python and Node.js Tests included
A woman sitting at a table looking at her cell phone
Photo by Vitaly Gariev on Unsplash
The short answer

A GitHub App JWT may not live longer than ten minutes. exp must be no more than 600 seconds after iat, and a JWT that asks for an hour — the comfortable default in every other system you have used — is refused before any signature or permission is considered.

You do not need GitHub to tell you this. A JWT is three base64url segments and the middle one is JSON, so exp - iat is a subtraction you can do on the machine that produced it, with no key, no network and nothing secret in the output. Set iat to sixty seconds in the past and exp to iat + 540, which leaves headroom on both ends, and mint a fresh JWT per exchange rather than caching one.

The problem in plain words

The message is precise and it still sends people to the wrong place, because the word people see is claim and the thing people suspect is the key. A 401 from a credentialled request means the credential, so the key gets regenerated, the PEM gets re-checked for stray newlines, the library gets swapped, and none of that touches a number that the code itself chose.

The ten-minute ceiling is also genuinely surprising. An hour is the habit everywhere else, an hour is what most JWT examples use, and there is nothing about signing a token that suggests the recipient will audit how long you asked for. So the value gets copied from a general-purpose snippet, works nowhere, and looks like a library incompatibility.

Then it hides. A retry loop that mints the same over-long JWT every thirty seconds produces a steady stream of identical 401s that looks like an outage rather than a defect, and if the JWT is minted once at process start and cached, the failure arrives minutes after a deploy that appeared to go fine. Neither shape points at the line that set the number.

exp set to anhourhabit from anothersystem401 from GET/appbefore anypermission checkKey regeneratedthe obvioussuspect firstSame 401returnsprose blamed onthe docsRetries everyminuteeach one wrong thesame wayand the key is replaced again
The key is right, the App exists and the signature verifies. The request is refused on a number, and the number was decided three lines above the request.

Why it happens

Ten minutes is the maximum, not the recommendation. The JWT authenticates the App itself and exists only to be exchanged for an installation access token. It is a short-lived bearer credential by design, so the ceiling is enforced by the server, and asking for longer is rejected rather than trimmed.

The two claims are yours, so the finding is local. iat and exp are numbers your signing code wrote into the payload. Reading them back needs no key: verification needs the key, decoding does not. That makes this one of the very few API failures you can prove entirely on your own machine, which is why the script does the arithmetic before it sends anything.

The ceiling is checked before the clock. A lifetime over 600 seconds is wrong whatever time it is, while expired and issued in the future both depend on a local clock that may itself be the thing that is wrong. Testing the claim-relative fault first means the script does not blame drift for a defect that is present in the payload regardless.

Clock drift is the neighbouring failure, not this one. If the machine signing the JWT runs a minute fast, iat lands in GitHub's future and the message names iat instead. The repair is different — backdate iat, and fix the clock — so the script reports it as its own state rather than folding it in here.

Caching a JWT is what turns this into an intermittent bug. A JWT held for the life of a process is fine for nine minutes and then is not. Mint one per token exchange; it costs a signature, and the exchange is already the slow part.

The fix, as a flow

The finding needs no network at all. A JWT is three base64url segments and two of the claims in the middle one are numbers your own code chose, so the ceiling check is a subtraction on the machine that did the choosing. The single GET is confirmation rather than diagnosis, and the script stops there: minting an installation token is a write, and this page never writes.

Decode the payload locallyno key, no requestLifetime over ten minutesprint the seconds to removeexp already in the pastthe JWT was cached too longiat ahead of this clockdrift, a different repairInside the ceilingthe 401 is not about exp
Four verdicts from two integers and a local clock. Nothing is transmitted to reach any of them, and the signature is never read.

How to fix it

Take the JWT your own code produced

Not a fresh one from a different code path: the actual value your integration puts in the Authorization header, exported to an environment variable for the length of this check. The whole argument is about what your signing code chose, so a JWT minted by the script would prove nothing about it.

Decode the payload, which needs no key

Split on dots and base64url-decode the middle segment. That is the claims JSON: iss, iat, exp. Verification would need the public key; reading does not, and no part of this requires the private key to be on the machine at all.

Subtract, and compare against 600

exp - iat is the requested lifetime in seconds. Over 600 and GitHub refuses it. 3600 is the number to look for first, because an hour is the value that gets copied in from elsewhere, and the script prints exactly how many seconds have to come off.

Check the clock separately, and after

iat against the local clock gives the drift. A signing machine running fast puts iat in GitHub's future and produces a different message with a different repair. Reporting the skew as a number rather than a verdict keeps the two apart.

Set iat to now minus sixty and exp to iat plus 540

Backdating iat a minute absorbs modest drift; a 540-second lifetime leaves a minute of headroom under the ceiling. Then mint per exchange rather than caching. Confirm with a single GET /app, which is the cheapest endpoint the JWT can reach, and stop there — minting an installation token is a write, and nothing on this page writes.

How to check it worked

Re-run against the JWT your fixed signing code produces. The state moves from exp-too-far-future to within-ceiling, and the live confirmation agrees.

GITHUB_APP_JWT=$(python3 sign_app_jwt.py) python3 github_app_jwt_claims.py
# iss=123456 iat=1772000000 exp=1772000540 lifetime=540s skew=-60s
# within-ceiling: the requested lifetime of 540s is inside the 600s ceiling.
# GET /app returned 200
# accepted: the JWT was accepted, so exp and iat are not the problem.

The full code

The finding needs no network at all, and that shapes the whole script: decoding, the ceiling arithmetic and the skew are pure functions over a string, and the single GET is confirmation rather than diagnosis. The JWT is read from the environment and never printed, in whole or in part; what the report contains is three claim values and a number of seconds. It stops at GET /app deliberately. Exchanging the JWT for an installation token is a write, and this section does not write.

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_app_jwt_claims.py
"""Audit the iat and exp claims of a GitHub App JWT before GitHub refuses them.

Read only, and mostly offline. The JWT is read from the environment, decoded
locally, and never printed: the report contains three claim values and a
number of seconds, which is everything needed to name the defect and nothing
that could be replayed.

A GitHub App JWT may live at most ten minutes. Both numbers that decide this
are chosen by your own signing code, and reading them back needs no key at all
- verification needs a key, decoding does not. The one request here is
GET /app, which confirms the local verdict. The script stops there on purpose:
exchanging a JWT for an installation access token is a write, and nothing in
this section writes.
"""
import argparse
import base64
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_app_jwt_claims")

API = "https://api.github.com"
UA = "github-app-jwt-claims/1.0"

# The server-enforced maximum lifetime, and the values worth using instead.
# 540 leaves a minute of headroom under the ceiling; backdating iat by 60
# absorbs modest drift on the signing machine.
CEILING = 600
RECOMMENDED_LIFETIME = 540
RECOMMENDED_BACKDATE = 60

# How far ahead of the local clock iat may sit before it is worth reporting as
# drift rather than as noise.
SKEW_GRACE = 30


def decode_segment(segment):
    """Base64url-decode one JWT segment into a dict. Pure.

    Returns None rather than raising: a malformed JWT is a finding to report,
    not an exception to propagate out of a diagnostic script.
    """
    text = str(segment or "")
    padded = text + "=" * (-len(text) % 4)
    try:
        raw = base64.urlsafe_b64decode(padded.encode("ascii"))
        value = json.loads(raw.decode("utf-8"))
    except Exception:
        return None
    return value if isinstance(value, dict) else None


def claims(jwt):
    """Split a JWT and decode its header and payload. Pure.

    The signature segment is counted, to check the shape, and then discarded
    without being decoded, returned or logged. Nothing downstream needs it and
    a diagnostic tool has no business handling it.
    """
    parts = str(jwt or "").strip().split(".")
    if len(parts) != 3:
        return None, None
    return decode_segment(parts[0]), decode_segment(parts[1])


def lifetime(payload):
    """The requested lifetime in seconds, or None if it cannot be computed. Pure."""
    if not isinstance(payload, dict):
        return None
    iat, exp = payload.get("iat"), payload.get("exp")
    if not isinstance(iat, (int, float)) or not isinstance(exp, (int, float)):
        return None
    if isinstance(iat, bool) or isinstance(exp, bool):
        return None
    return int(exp) - int(iat)


def skew(payload, now):
    """How far iat sits from the local clock, in seconds. Pure.

    Negative means the JWT was backdated, which is what you want. Positive
    means the signing clock is ahead of this one.
    """
    if not isinstance(payload, dict):
        return None
    iat = payload.get("iat")
    if not isinstance(iat, (int, float)) or isinstance(iat, bool):
        return None
    return int(iat) - int(now)


def audit(payload, now):
    """Turn a decoded payload and a clock reading into a finding. Pure.

    Order matters. The ceiling is checked before anything clock-relative,
    because a lifetime over 600 seconds is wrong whatever time it is, while
    "expired" and "issued in the future" both depend on a local clock that may
    itself be the defect. Testing the claim-relative fault first stops the
    script blaming drift for a payload that is wrong regardless.
    """
    if not isinstance(payload, dict):
        return ("unreadable",
                "the middle segment did not decode to a JSON object, so this "
                "is not a well-formed JWT. Check what the signing code "
                "returned before looking at any claim.")
    if "iat" not in payload:
        return ("no-iat",
                "there is no iat claim. GitHub measures the lifetime from it, "
                "so a JWT without one cannot be judged against the ten minute "
                "ceiling and is refused.")
    if "exp" not in payload:
        return ("no-exp",
                "there is no exp claim, so the JWT never expires as far as the "
                "payload is concerned. That is exactly what the ceiling exists "
                "to prevent, and it is refused.")

    span = lifetime(payload)
    if span is None:
        return ("non-numeric-claim",
                "iat and exp must be numeric seconds since the epoch. One of "
                "them is not a number, which usually means a date string or a "
                "millisecond timestamp went in where seconds were expected.")
    if span <= 0:
        return ("exp-not-after-iat",
                "exp is %d second(s) before iat, so the JWT is expired at the "
                "moment it is signed." % -span)
    if span > CEILING:
        return ("exp-too-far-future",
                "the requested lifetime is %ds, which is %ds over the %ds "
                "ceiling. Remove %ds from exp and the claim is legal."
                % (span, span - CEILING, CEILING, span - RECOMMENDED_LIFETIME))

    drift = skew(payload, now)
    exp = int(payload["exp"])
    if exp <= int(now):
        return ("already-expired",
                "the lifetime is legal at %ds, and this JWT expired %ds ago. "
                "A JWT minted once and cached for the life of a process fails "
                "exactly like this, minutes after a deploy that looked fine."
                % (span, int(now) - exp))
    if drift is not None and drift > SKEW_GRACE:
        return ("iat-in-the-future",
                "the lifetime is legal at %ds, and iat is %ds ahead of this "
                "clock. If the signing machine is ahead of GitHub, iat lands "
                "in its future and the message names iat rather than exp. "
                "That is a different repair: backdate iat and fix the clock."
                % (span, drift))
    if exp - int(now) < 30:
        return ("expiring-imminently",
                "the lifetime is legal at %ds and only %ds of it remain, which "
                "is not enough to survive a retry. Mint per exchange rather "
                "than caching." % (span, exp - int(now)))
    return ("within-ceiling",
            "the requested lifetime of %ds is inside the %ds ceiling."
            % (span, CEILING))


def recommend(payload, now):
    """The claim values that would have worked. Pure."""
    iat = int(now) - RECOMMENDED_BACKDATE
    span = lifetime(payload)
    return {"iat": iat, "exp": iat + RECOMMENDED_LIFETIME,
            "lifetime": RECOMMENDED_LIFETIME,
            "seconds_to_remove": max((span or 0) - RECOMMENDED_LIFETIME, 0)}


def interpret(status, message):
    """Map a live GET /app response to the defect it names. Pure.

    GitHub's messages about these claims are specific and stable, and each one
    points at a different line of the signing code. Matched on the distinctive
    phrase rather than on the whole sentence.
    """
    if status == 200:
        return ("accepted",
                "the JWT was accepted, so exp and iat are not the problem.")
    text = str(message or "").lower()
    if "too far in the future" in text:
        return ("exp-too-far-future",
                "GitHub says exp is too far ahead of iat, which is the ceiling.")
    if "issued at" in text or "'iat'" in text:
        return ("iat-in-the-future",
                "GitHub says iat is in its future, which is clock drift on the "
                "signing machine rather than a lifetime problem.")
    if "numeric value representing the future" in text or "expired" in text:
        return ("already-expired",
                "GitHub says exp is not in the future, so this JWT was minted "
                "too long ago or the clock is behind.")
    if "could not be decoded" in text:
        return ("undecodable",
                "GitHub could not decode the JWT at all, which is a signing or "
                "encoding fault rather than a claim one.")
    if "integration not found" in text:
        return ("wrong-app-or-key",
                "the claims are acceptable and the App they name cannot be "
                "found, so iss or the signing key belongs to something else.")
    return ("unrelated",
            "the response does not mention a claim, so this failure has "
            "another cause.")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--offline", action="store_true",
                    help="skip the confirming GET /app and report the local "
                         "arithmetic only")
    args = ap.parse_args()

    jwt = os.environ.get("GITHUB_APP_JWT")
    if not jwt:
        log.error("set GITHUB_APP_JWT to the JWT your own signing code "
                  "produces. A JWT minted by this script would prove nothing "
                  "about yours")
        return 2

    now = time.time()
    header, payload = claims(jwt)
    if payload is None:
        log.error("the JWT did not decode into three segments with a JSON "
                  "payload in the middle")
        state, detail = audit(None, now)
        log.info("%s: %s", state, detail)
        return 1

    # Claim values only. The signature is never decoded and the JWT is never
    # printed, in whole or in part.
    log.info("iss=%s iat=%s exp=%s lifetime=%ss skew=%ss",
             payload.get("iss", "absent"), payload.get("iat", "absent"),
             payload.get("exp", "absent"),
             lifetime(payload) if lifetime(payload) is not None else "unknown",
             skew(payload, now) if skew(payload, now) is not None else "unknown")
    if isinstance(header, dict) and header.get("alg") not in (None, "RS256"):
        log.info("note: alg is %s rather than RS256, which is a different "
                 "defect from this one", header.get("alg"))

    state, detail = audit(payload, now)
    log.info("%s: %s", state, detail)

    if not args.offline:
        r = requests.get(API + "/app", timeout=30, headers={
            "Authorization": "Bearer " + jwt,
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
            "User-Agent": UA,
        })
        try:
            body = r.json()
        except ValueError:
            body = None
        message = body.get("message") if isinstance(body, dict) else None
        log.info("GET /app returned %d", r.status_code)
        live_state, live_detail = interpret(r.status_code, message)
        log.info("%s: %s", live_state, live_detail)

    if state in ("exp-too-far-future", "no-exp", "no-iat", "exp-not-after-iat",
                 "non-numeric-claim", "already-expired", "expiring-imminently"):
        want = recommend(payload, now)
        log.info("repair: set iat=%d (now minus %ds) and exp=%d (iat plus "
                 "%ds), then mint a fresh JWT per token exchange rather than "
                 "caching one", want["iat"], RECOMMENDED_BACKDATE, want["exp"],
                 want["lifetime"])
        if want["seconds_to_remove"]:
            log.info("repair: that is %d second(s) off the current exp",
                     want["seconds_to_remove"])

    print(json.dumps({"iss": payload.get("iss"), "iat": payload.get("iat"),
                      "exp": payload.get("exp"), "lifetime": lifetime(payload),
                      "skew_seconds": skew(payload, now), "state": state},
                     indent=2))
    return 0 if state == "within-ceiling" else 1


if __name__ == "__main__":
    sys.exit(main())
github-app-jwt-claims.mjs
/**
 * Audit the iat and exp claims of a GitHub App JWT before GitHub refuses them.
 *
 * Read only, and mostly offline. The JWT is read from the environment,
 * decoded locally, and never printed: the report contains three claim values
 * and a number of seconds.
 *
 * Decoding needs no key; verification would. The single request is GET /app,
 * which confirms the local verdict. The script stops there on purpose:
 * exchanging a JWT for an installation access token is a write, and nothing
 * in this section writes.
 */
const API = 'https://api.github.com';
const UA = 'github-app-jwt-claims/1.0';

/** The server-enforced maximum lifetime, and the values worth using instead. */
export const CEILING = 600;
export const RECOMMENDED_LIFETIME = 540;
export const RECOMMENDED_BACKDATE = 60;

/** How far ahead of the local clock iat may sit before it is worth reporting. */
export const SKEW_GRACE = 30;

/** Base64url-decode one JWT segment into an object. Pure. null on anything odd. */
export function decodeSegment(segment) {
  try {
    const raw = Buffer.from(String(segment ?? ''), 'base64url').toString('utf8');
    const value = JSON.parse(raw);
    return (value && typeof value === 'object' && !Array.isArray(value)) ? value : null;
  } catch {
    return null;
  }
}

/**
 * Split a JWT and decode its header and payload. Pure.
 * The signature segment is counted and then discarded without being decoded,
 * returned or logged.
 */
export function claims(jwt) {
  const parts = String(jwt ?? '').trim().split('.');
  if (parts.length !== 3) return [null, null];
  return [decodeSegment(parts[0]), decodeSegment(parts[1])];
}

const numeric = (v) => typeof v === 'number' && Number.isFinite(v);

/** The requested lifetime in seconds, or null. Pure. */
export function lifetime(payload) {
  if (!payload || typeof payload !== 'object') return null;
  if (!numeric(payload.iat) || !numeric(payload.exp)) return null;
  return Math.trunc(payload.exp) - Math.trunc(payload.iat);
}

/** How far iat sits from the local clock, in seconds. Negative is backdated. Pure. */
export function skew(payload, now) {
  if (!payload || typeof payload !== 'object' || !numeric(payload.iat)) return null;
  return Math.trunc(payload.iat) - Math.trunc(now);
}

/**
 * Turn a decoded payload and a clock reading into a finding. Pure.
 * The ceiling is checked before anything clock-relative, because a lifetime
 * over 600 seconds is wrong whatever time it is.
 */
export function audit(payload, now) {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
    return ['unreadable',
      'the middle segment did not decode to a JSON object, so this is not a ' +
      'well-formed JWT. Check what the signing code returned before looking ' +
      'at any claim.'];
  }
  if (!('iat' in payload)) {
    return ['no-iat',
      'there is no iat claim. GitHub measures the lifetime from it, so a JWT ' +
      'without one cannot be judged against the ten minute ceiling and is refused.'];
  }
  if (!('exp' in payload)) {
    return ['no-exp',
      'there is no exp claim, so the JWT never expires as far as the payload ' +
      'is concerned. That is exactly what the ceiling exists to prevent, and ' +
      'it is refused.'];
  }

  const span = lifetime(payload);
  if (span === null) {
    return ['non-numeric-claim',
      'iat and exp must be numeric seconds since the epoch. One of them is ' +
      'not a number, which usually means a date string or a millisecond ' +
      'timestamp went in where seconds were expected.'];
  }
  if (span <= 0) {
    return ['exp-not-after-iat',
      `exp is ${-span} second(s) before iat, so the JWT is expired at the ` +
      'moment it is signed.'];
  }
  if (span > CEILING) {
    return ['exp-too-far-future',
      `the requested lifetime is ${span}s, which is ${span - CEILING}s over ` +
      `the ${CEILING}s ceiling. Remove ${span - RECOMMENDED_LIFETIME}s from ` +
      'exp and the claim is legal.'];
  }

  const drift = skew(payload, now);
  const exp = Math.trunc(payload.exp);
  if (exp <= Math.trunc(now)) {
    return ['already-expired',
      `the lifetime is legal at ${span}s, and this JWT expired ` +
      `${Math.trunc(now) - exp}s ago. A JWT minted once and cached for the ` +
      'life of a process fails exactly like this, minutes after a deploy that ' +
      'looked fine.'];
  }
  if (drift !== null && drift > SKEW_GRACE) {
    return ['iat-in-the-future',
      `the lifetime is legal at ${span}s, and iat is ${drift}s ahead of this ` +
      'clock. If the signing machine is ahead of GitHub, iat lands in its ' +
      'future and the message names iat rather than exp. That is a different ' +
      'repair: backdate iat and fix the clock.'];
  }
  if (exp - Math.trunc(now) < 30) {
    return ['expiring-imminently',
      `the lifetime is legal at ${span}s and only ${exp - Math.trunc(now)}s of ` +
      'it remain, which is not enough to survive a retry. Mint per exchange ' +
      'rather than caching.'];
  }
  return ['within-ceiling',
    `the requested lifetime of ${span}s is inside the ${CEILING}s ceiling.`];
}

/** The claim values that would have worked. Pure. */
export function recommend(payload, now) {
  const iat = Math.trunc(now) - RECOMMENDED_BACKDATE;
  const span = lifetime(payload);
  return {
    iat,
    exp: iat + RECOMMENDED_LIFETIME,
    lifetime: RECOMMENDED_LIFETIME,
    seconds_to_remove: Math.max((span ?? 0) - RECOMMENDED_LIFETIME, 0),
  };
}

/** Map a live GET /app response to the defect it names. Pure. */
export function interpret(status, message) {
  if (status === 200) {
    return ['accepted', 'the JWT was accepted, so exp and iat are not the problem.'];
  }
  const text = String(message ?? '').toLowerCase();
  if (text.includes('too far in the future')) {
    return ['exp-too-far-future',
      'GitHub says exp is too far ahead of iat, which is the ceiling.'];
  }
  if (text.includes('issued at') || text.includes("'iat'")) {
    return ['iat-in-the-future',
      'GitHub says iat is in its future, which is clock drift on the signing ' +
      'machine rather than a lifetime problem.'];
  }
  if (text.includes('numeric value representing the future') || text.includes('expired')) {
    return ['already-expired',
      'GitHub says exp is not in the future, so this JWT was minted too long ' +
      'ago or the clock is behind.'];
  }
  if (text.includes('could not be decoded')) {
    return ['undecodable',
      'GitHub could not decode the JWT at all, which is a signing or encoding ' +
      'fault rather than a claim one.'];
  }
  if (text.includes('integration not found')) {
    return ['wrong-app-or-key',
      'the claims are acceptable and the App they name cannot be found, so ' +
      'iss or the signing key belongs to something else.'];
  }
  return ['unrelated',
    'the response does not mention a claim, so this failure has another cause.'];
}

async function main() {
  const jwt = process.env.GITHUB_APP_JWT;
  if (!jwt) {
    console.error('set GITHUB_APP_JWT to the JWT your own signing code ' +
      'produces. A JWT minted by this script would prove nothing about yours');
    process.exitCode = 2;
    return;
  }
  const offline = process.argv.includes('--offline');
  const now = Date.now() / 1000;
  const [header, payload] = claims(jwt);
  if (!payload) {
    console.error('the JWT did not decode into three segments with a JSON ' +
      'payload in the middle');
    const [state, detail] = audit(null, now);
    console.log(`${state}: ${detail}`);
    process.exitCode = 1;
    return;
  }

  // Claim values only. The signature is never decoded and the JWT is never
  // printed, in whole or in part.
  console.log(`iss=${payload.iss ?? 'absent'} iat=${payload.iat ?? 'absent'} ` +
    `exp=${payload.exp ?? 'absent'} lifetime=${lifetime(payload) ?? 'unknown'}s ` +
    `skew=${skew(payload, now) ?? 'unknown'}s`);
  if (header && header.alg && header.alg !== 'RS256') {
    console.log(`note: alg is ${header.alg} rather than RS256, which is a ` +
      'different defect from this one');
  }

  const [state, detail] = audit(payload, now);
  console.log(`${state}: ${detail}`);

  if (!offline) {
    const res = await fetch(`${API}/app`, {
      headers: {
        Authorization: `Bearer ${jwt}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'User-Agent': UA,
      },
    });
    let body = null;
    try { body = await res.json(); } catch { body = null; }
    const message = body && typeof body === 'object' ? body.message : null;
    console.log(`GET /app returned ${res.status}`);
    const [liveState, liveDetail] = interpret(res.status, message);
    console.log(`${liveState}: ${liveDetail}`);
  }

  const broken = ['exp-too-far-future', 'no-exp', 'no-iat', 'exp-not-after-iat',
    'non-numeric-claim', 'already-expired', 'expiring-imminently'];
  if (broken.includes(state)) {
    const want = recommend(payload, now);
    console.log(`repair: set iat=${want.iat} (now minus ${RECOMMENDED_BACKDATE}s) ` +
      `and exp=${want.exp} (iat plus ${want.lifetime}s), then mint a fresh JWT ` +
      'per token exchange rather than caching one');
    if (want.seconds_to_remove) {
      console.log(`repair: that is ${want.seconds_to_remove} second(s) off the ` +
        'current exp');
    }
  }

  console.log(JSON.stringify({
    iss: payload.iss ?? null,
    iat: payload.iat ?? null,
    exp: payload.exp ?? null,
    lifetime: lifetime(payload),
    skew_seconds: skew(payload, now),
    state,
  }, null, 2));
  process.exitCode = state === 'within-ceiling' ? 0 : 1;
}

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

Add a test

Every JWT in these tests is assembled from claims by a three-line helper, with the literal sig where a signature would be: obviously fake, and short enough that nobody will ever mistake it for a credential. That is the whole benefit of a check that needs no key — an hour-long lifetime, a clock a minute fast and a token that expired yesterday are all three lines each, and the ordering rule (the ceiling before the clock) gets a test of its own.

test_github_app_jwt_claims.py
import base64
import json

from github_app_jwt_claims import (
    CEILING, audit, claims, decode_segment, interpret, lifetime, recommend, skew,
)

NOW = 1_772_000_000


def seg(value):
    raw = base64.urlsafe_b64encode(json.dumps(value).encode()).decode()
    return raw.rstrip("=")


def token(payload, header=None):
    """An obviously fake JWT: real claims, and the word sig for a signature."""
    return "%s.%s.sig" % (seg(header or {"alg": "RS256", "typ": "JWT"}),
                          seg(payload))


def test_a_segment_decodes_without_any_key():
    assert decode_segment(seg({"iss": "123456"})) == {"iss": "123456"}
    assert decode_segment("not base64 at all!!") is None
    assert decode_segment(seg([1, 2])) is None


def test_a_jwt_splits_into_a_header_and_a_payload():
    header, payload = claims(token({"iat": NOW, "exp": NOW + 540}))
    assert header["alg"] == "RS256"
    assert payload["exp"] - payload["iat"] == 540


def test_something_that_is_not_three_segments_decodes_to_nothing():
    assert claims("abc.def") == (None, None)
    assert claims("") == (None, None)
    assert claims(None) == (None, None)


def test_lifetime_and_skew_are_plain_arithmetic():
    payload = {"iat": NOW - 60, "exp": NOW + 480}
    assert lifetime(payload) == 540
    assert skew(payload, NOW) == -60
    assert lifetime({"iat": "2026-01-01", "exp": NOW}) is None
    assert skew({}, NOW) is None


def test_an_hour_long_jwt_is_the_headline_finding():
    state, detail = audit({"iat": NOW, "exp": NOW + 3600}, NOW)
    assert state == "exp-too-far-future"
    assert "3600s" in detail
    assert "3000s over" in detail


def test_the_ceiling_is_checked_before_the_clock():
    # Both faults present: an hour-long lifetime signed by a clock ten minutes
    # fast. The payload fault wins, because it is true whatever the time is.
    state, _ = audit({"iat": NOW + 600, "exp": NOW + 600 + 3600}, NOW)
    assert state == "exp-too-far-future"


def test_exactly_the_ceiling_is_still_legal():
    assert audit({"iat": NOW, "exp": NOW + CEILING}, NOW)[0] == "within-ceiling"
    assert audit({"iat": NOW, "exp": NOW + CEILING + 1}, NOW)[0] == "exp-too-far-future"


def test_a_missing_claim_is_named_rather_than_computed_around():
    assert audit({"exp": NOW + 300}, NOW)[0] == "no-iat"
    assert audit({"iat": NOW}, NOW)[0] == "no-exp"


def test_milliseconds_where_seconds_were_expected_are_caught():
    state, detail = audit({"iat": "1772000000", "exp": "1772000540"}, NOW)
    assert state == "non-numeric-claim"
    assert "millisecond" in detail


def test_exp_before_iat_is_its_own_state():
    state, detail = audit({"iat": NOW, "exp": NOW - 10}, NOW)
    assert state == "exp-not-after-iat"
    assert "10 second(s) before" in detail


def test_a_cached_jwt_that_ran_out_is_told_apart_from_a_long_one():
    state, detail = audit({"iat": NOW - 900, "exp": NOW - 360}, NOW)
    assert state == "already-expired"
    assert "cached" in detail


def test_a_fast_signing_clock_is_reported_as_drift_and_not_as_the_ceiling():
    state, detail = audit({"iat": NOW + 300, "exp": NOW + 540}, NOW)
    assert state == "iat-in-the-future"
    assert "different repair" in detail


def test_a_jwt_about_to_expire_is_flagged_before_it_does():
    assert audit({"iat": NOW - 580, "exp": NOW + 20}, NOW)[0] == "expiring-imminently"


def test_a_healthy_jwt_says_so_without_qualification():
    state, detail = audit({"iat": NOW - 60, "exp": NOW + 480}, NOW)
    assert state == "within-ceiling"
    assert "540s" in detail


def test_the_recommendation_is_a_pair_of_numbers_to_paste():
    want = recommend({"iat": NOW, "exp": NOW + 3600}, NOW)
    assert want["iat"] == NOW - 60
    assert want["exp"] == NOW + 480
    assert want["seconds_to_remove"] == 3060


def test_the_live_messages_map_to_the_same_states_as_the_local_check():
    assert interpret(200, None)[0] == "accepted"
    assert interpret(401, "'Expiration time' claim ('exp') is too far in the "
                          "future")[0] == "exp-too-far-future"
    assert interpret(401, "'Issued at' claim ('iat') is in the "
                          "future")[0] == "iat-in-the-future"
    assert interpret(401, "'Expiration time' claim ('exp') must be a numeric "
                          "value representing the future time"
                     )[0] == "already-expired"
    assert interpret(401, "A JSON web token could not be "
                          "decoded")[0] == "undecodable"
    assert interpret(404, "Integration not found")[0] == "wrong-app-or-key"
    assert interpret(403, "Resource not accessible by integration")[0] == "unrelated"
github-app-jwt-claims.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  CEILING, audit, claims, decodeSegment, interpret, lifetime, recommend, skew,
} from './github-app-jwt-claims.mjs';

const NOW = 1772000000;

const seg = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');

/** An obviously fake JWT: real claims, and the word sig for a signature. */
const token = (payload, header = { alg: 'RS256', typ: 'JWT' }) =>
  `${seg(header)}.${seg(payload)}.sig`;

test('a segment decodes without any key', () => {
  assert.deepEqual(decodeSegment(seg({ iss: '123456' })), { iss: '123456' });
  assert.equal(decodeSegment('%%%'), null);
  assert.equal(decodeSegment(seg([1, 2])), null);
});

test('a jwt splits into a header and a payload', () => {
  const [header, payload] = claims(token({ iat: NOW, exp: NOW + 540 }));
  assert.equal(header.alg, 'RS256');
  assert.equal(payload.exp - payload.iat, 540);
});

test('something that is not three segments decodes to nothing', () => {
  assert.deepEqual(claims('abc.def'), [null, null]);
  assert.deepEqual(claims(''), [null, null]);
  assert.deepEqual(claims(null), [null, null]);
});

test('lifetime and skew are plain arithmetic', () => {
  const payload = { iat: NOW - 60, exp: NOW + 480 };
  assert.equal(lifetime(payload), 540);
  assert.equal(skew(payload, NOW), -60);
  assert.equal(lifetime({ iat: '2026-01-01', exp: NOW }), null);
  assert.equal(skew({}, NOW), null);
});

test('an hour long jwt is the headline finding', () => {
  const [state, detail] = audit({ iat: NOW, exp: NOW + 3600 }, NOW);
  assert.equal(state, 'exp-too-far-future');
  assert.match(detail, /3600s/);
  assert.match(detail, /3000s over/);
});

test('the ceiling is checked before the clock', () => {
  const [state] = audit({ iat: NOW + 600, exp: NOW + 600 + 3600 }, NOW);
  assert.equal(state, 'exp-too-far-future');
});

test('exactly the ceiling is still legal', () => {
  assert.equal(audit({ iat: NOW, exp: NOW + CEILING }, NOW)[0], 'within-ceiling');
  assert.equal(audit({ iat: NOW, exp: NOW + CEILING + 1 }, NOW)[0], 'exp-too-far-future');
});

test('a missing claim is named rather than computed around', () => {
  assert.equal(audit({ exp: NOW + 300 }, NOW)[0], 'no-iat');
  assert.equal(audit({ iat: NOW }, NOW)[0], 'no-exp');
});

test('milliseconds where seconds were expected are caught', () => {
  const [state, detail] = audit({ iat: '1772000000', exp: '1772000540' }, NOW);
  assert.equal(state, 'non-numeric-claim');
  assert.match(detail, /millisecond/);
});

test('exp before iat is its own state', () => {
  const [state, detail] = audit({ iat: NOW, exp: NOW - 10 }, NOW);
  assert.equal(state, 'exp-not-after-iat');
  assert.match(detail, /10 second\(s\) before/);
});

test('a cached jwt that ran out is told apart from a long one', () => {
  const [state, detail] = audit({ iat: NOW - 900, exp: NOW - 360 }, NOW);
  assert.equal(state, 'already-expired');
  assert.match(detail, /cached/);
});

test('a fast signing clock is reported as drift and not as the ceiling', () => {
  const [state, detail] = audit({ iat: NOW + 300, exp: NOW + 540 }, NOW);
  assert.equal(state, 'iat-in-the-future');
  assert.match(detail, /different repair/);
});

test('a jwt about to expire is flagged before it does', () => {
  assert.equal(audit({ iat: NOW - 580, exp: NOW + 20 }, NOW)[0], 'expiring-imminently');
});

test('a healthy jwt says so without qualification', () => {
  const [state, detail] = audit({ iat: NOW - 60, exp: NOW + 480 }, NOW);
  assert.equal(state, 'within-ceiling');
  assert.match(detail, /540s/);
});

test('the recommendation is a pair of numbers to paste', () => {
  const want = recommend({ iat: NOW, exp: NOW + 3600 }, NOW);
  assert.equal(want.iat, NOW - 60);
  assert.equal(want.exp, NOW + 480);
  assert.equal(want.seconds_to_remove, 3060);
});

test('the live messages map to the same states as the local check', () => {
  assert.equal(interpret(200, null)[0], 'accepted');
  assert.equal(interpret(401, "'Expiration time' claim ('exp') is too far in the future")[0],
    'exp-too-far-future');
  assert.equal(interpret(401, "'Issued at' claim ('iat') is in the future")[0],
    'iat-in-the-future');
  assert.equal(interpret(401,
    "'Expiration time' claim ('exp') must be a numeric value representing the future time")[0],
  'already-expired');
  assert.equal(interpret(401, 'A JSON web token could not be decoded')[0], 'undecodable');
  assert.equal(interpret(404, 'Integration not found')[0], 'wrong-app-or-key');
  assert.equal(interpret(403, 'Resource not accessible by integration')[0], 'unrelated');
});

FAQ

Why ten minutes rather than an hour?

Because the JWT is not the working credential. Its only job is to prove the App owns its private key for long enough to exchange it for an installation access token, and that exchange takes milliseconds. A short ceiling limits what a leaked JWT is worth: an hour of App-level authority is a meaningful prize, ten minutes is barely worth stealing. The installation token that comes out of the exchange lasts an hour instead, and that one is scoped to a single installation and its permissions rather than to the whole App.

Is it exp minus iat, or exp minus now?

GitHub measures the requested lifetime as exp minus iat, so a JWT can be refused for being too long even when it would expire two minutes from now by your clock. That is why the check is local arithmetic rather than a comparison against the current time, and why the script tests the ceiling before it tests anything clock-relative: a lifetime over 600 seconds is wrong regardless of what time either machine thinks it is.

I set exp to iat plus 600 exactly and it still fails sometimes. Why?

Because iat is compared against GitHub's clock, not yours, and a signing machine running even slightly fast pushes the whole window forward. At exactly 600 there is no headroom for that, so the request lands on the wrong side of the boundary intermittently, which is the worst way for it to fail. Setting iat sixty seconds in the past and exp to iat plus 540 leaves slack at both ends, and costs nothing since the JWT is discarded after the exchange anyway.

Can I reuse one JWT for several installations?

You can, within its lifetime, since the JWT authenticates the App rather than any one installation. The reason not to hold it longer than a single burst of work is that a cached JWT is the direct cause of the intermittent variant of this failure: it works for the first several minutes after a deploy and then starts returning 401 with a message about exp, which looks like an unrelated outage. Signing is cheap. Mint one per exchange and the class of bug disappears.

The message names iat rather than exp. Is that the same problem?

No, and the difference is which machine is wrong. Too far in the future means the lifetime you asked for exceeds the ceiling, which is a defect in your code. An iat in the future means the moment you claim to have signed at has not happened yet as far as GitHub is concerned, which is clock drift on the signing host. The repair for the first is a smaller exp; the repair for the second is to backdate iat by a minute and then to fix the clock, because drift that is large enough to break a JWT will break other things too.

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.