Skip to content

Diagnostic GitHub API

a hardcoded installation id stops matching reality

The installation id was copied out of a settings URL in 2024 and pasted into an environment variable, where it has sat ever since. Last Tuesday an admin at one customer uninstalled the App during a security review and put it straight back, which they were entitled to do and which nobody told you about. Since then the token call for that customer has returned 404, and the error says nothing about installations.

Read-only token Python and Node.js Tests included
A blue car being loaded onto a flatbed truck
Photo by fr0ggy5 on Unsplash
The short answer

Installation ids are not stable identifiers. Uninstalling and reinstalling an App produces a new installation, with a new id; the old id refers to a record that no longer exists, and the endpoint that mints a token for it answers 404. Any configuration that stores the id once and never re-reads it breaks on the next reinstall, which is an event you do not control and do not get told about.

Read GET /app/installations with the App's JWT. It lists every installation the App currently has, with id, account.login, repository_selection and created_at. If your configured id is not on that list it is stale. If it is on the list but sits against a different account than the one your configuration files it under, that is the worse case and the one worth checking for: the integration is not failing, it is working against somebody else's organization. The durable repair is to stop storing the id at all — resolve it per account from GET /orgs/{org}/installation, or take it from installation.id on the incoming webhook — and to key your own state on the account login, which does not change when an App is reinstalled.

The problem in plain words

The 404 does not mention installations, so the search starts in the wrong place. A 404 from the token endpoint reads as a routing problem or a permissions problem, and the first hour goes into the private key, the JWT, the clock, the header. All of it is fine. The request is well formed and correctly signed, and it is asking about a record that was deleted.

Meanwhile the id looks like the most trustworthy thing in the configuration. It is a number, it has been there for two years, it is in the runbook, and it worked this morning. Nobody suspects a constant. The change that broke it happened in an organization you have no visibility into, was made by somebody exercising a documented right, and left no trace in your systems at all except the failure.

The quiet version is worse and nobody looks for it. If a stale id is reused, or if a configuration file has drifted — staging's id in production, two customers' entries transposed during an onboarding — the id still resolves. Tokens mint, calls succeed, and every request goes to the wrong organization. There is no error to alert on, because from the API's point of view nothing is wrong. The only signal is that the account behind the id is not the account you think it is, and the only way to see it is to look.

Id pasted froma URLinto config, twoyears agoCustomerreinstallsa new id iscreatedToken call 404sand names no causeKey and JWTcheckedboth are perfectlyfineFiled as flakyuntil the nextreinstalland the id is pasted in again
The change happened inside a customer's organization, was entirely legitimate, and left no trace anywhere in your systems except the failure it caused.

Why it happens

An installation is a record, not a relationship. Uninstalling deletes the record. Reinstalling creates a new one, with a new id, new timestamps and a fresh grant. The App and the account are the same, the connection between them is a different object, and nothing carries over. That is why the id is the wrong thing to persist: it names an instance of a relationship rather than the relationship.

The failure mode is asymmetric. A stale id fails loudly and immediately, which is annoying and survivable. A crossed id — one that resolves to an account other than the one it is filed under — does not fail at all. Everything is 200. The damage is that the integration reads, and in a writing integration writes, against the wrong organization. A check that only asks does my id still work will never find it, so the check has to compare the id against the account.

The account login is the stable key. Logins can be renamed, but the account id and the login both survive an uninstall and a reinstall, while the installation id does not. Keying stored state on the installation id means a reinstall silently orphans everything you knew about that customer; keying it on the account means a reinstall is a change of one field.

The current id is one GET away, per account. GET /orgs/{org}/installation with the JWT returns the installation for that organization as it exists right now, and the equivalent routes exist for users and repositories. Resolving at runtime costs one cheap request that can be cached for the life of the process, which is a good trade against a configuration value that goes wrong without telling you.

The webhook already carries it. Every App webhook payload includes an installation object with the id on it. An integration that reacts to events rather than polling never needs to look the id up: the delivery that woke it names the installation it concerns, and that value is correct by construction because GitHub just used it.

The fix, as a flow

One paginated GET builds the authoritative list, and each configured pair is then looked up twice against it: once by id, to see whether it still exists, and once by account, to see what that account's id is today. Running only the first lookup is how the serious finding survives an audit, because a crossed id exists perfectly well. The endpoint that mints a token is a write and is never called, so the script explains the 404 rather than reproducing it.

Each configured idlooked up by id and by accountExists, wrong accountsilently hits another orgGone, account still therereinstalled under a new idGone, account gone toouninstalled and not replacedMatches its accountand was not recreated since
The first row is the one worth building the check for. It is the only outcome here that never produces an error of any kind.

How to fix it

List what the App can actually see

GET /app/installations with the App's JWT, paginated. That is the authoritative set: every installation the App has right now, each with its id, its account and when it was created. Everything else in this check is a comparison against this list.

Look up each configured id twice

Once by id, to see whether it exists. Once by account, to see what the current id for that account is. The two lookups answer different questions, and running only the first is how a crossed id survives an audit: it exists, so the check passes, and it belongs to somebody else.

Treat an account mismatch as the serious finding

An id that resolves to a different account than the one it is filed under should stop a deploy, not raise a ticket. Nothing about it fails on its own, so there is no second chance to notice. The script reports it first and separately from the stale ones for exactly that reason.

Compare created_at against when the id was recorded

An installation whose created_at is newer than the date you wrote the id down is a reinstall, whether or not the id happens to still match. That is the early warning: it tells you a customer removed and re-added the App, which is worth knowing even in the cases where nothing broke.

Stop storing the id

Resolve it from GET /orgs/{org}/installation at startup, or take it from installation.id on the webhook that triggered the work, and cache it in memory rather than in configuration. Key stored state on the account login. After that a reinstall is an event your integration handles by continuing to work.

How to check it worked

Once the ids are resolved at runtime, the same audit reports every configured account as current, and keeps doing so through the next reinstall without anybody editing anything.

GITHUB_APP_JWT=$(python3 sign_app_jwt.py) \
  GITHUB_INSTALLATION_MAP='acme-corp=41234567,beta-inc=41234568' \
  python3 github_installation_id_drift.py
# 12 installation(s) visible to this App
# stale: acme-corp is configured as 41234567, which this App no longer has.
# The current installation for acme-corp is 55120044, created 2026-08-25T08:02:11Z
# crossed: beta-inc is configured as 41234568, which exists and belongs to
# gamma-labs. Nothing about this fails: it works against the wrong account.
# repair: resolve the id per account at runtime, and key stored state on the login

# after the resolution moves into the code
# current: acme-corp resolves to 55120044

The full code

One paginated GET builds the authoritative list, and one optional GET per account confirms it from the other direction. Everything else is two dictionaries and a careful comparison: ids are matched as text because they arrive from environment variables and JSON files as often as from the API, logins are matched case-insensitively because GitHub treats them that way, and the four outcomes are kept apart deliberately. The endpoint that mints an installation token is a write and is not called here, so the script never reproduces the 404 that started the investigation; it explains it.

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_installation_id_drift.py
"""Find configured GitHub App installation ids that no longer mean what they did.

Read only. One paginated GET over the App's own installations with the App JWT,
and one optional GET per configured account. Nothing is minted or changed. The
endpoint that mints an installation access token is a write, so this script does
not call it and never reproduces the 404 that usually starts the investigation.

Installation ids are not stable. Uninstalling and reinstalling an App creates a
new installation with a new id, so an id copied out of a URL once either stops
resolving or, if it was transposed or reused, resolves against an account that
is not the one your configuration believes.

Environment:

    GITHUB_APP_JWT           the JWT your own signing code produced
    GITHUB_INSTALLATION_MAP  account=id pairs, comma separated, or a JSON object
    GITHUB_MAP_RECORDED_AT   optional ISO date the map was last written
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone

import requests

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

API = "https://api.github.com"
UA = "github-installation-id-drift/1.0"

# The one finding that never announces itself. A stale id 404s on the next call;
# a crossed id succeeds forever against the wrong organization, so it is ordered
# first in every report this script prints.
SILENT = ("crossed",)


def parse_map(text):
    """account=id pairs, or a JSON object, into a plain dict. Pure.

    Accepts both because this value lives in an environment variable in some
    deployments and in a config file in others, and a checker that only reads
    one of the two shapes gets skipped in half of them.
    """
    raw = str(text or "").strip()
    if not raw:
        return {}
    if raw.startswith("{"):
        try:
            loaded = json.loads(raw)
        except ValueError:
            return {}
        return {str(k).strip().lower(): str(v).strip()
                for k, v in loaded.items() if str(k).strip()}
    out = {}
    for chunk in raw.replace(";", ",").split(","):
        if "=" not in chunk:
            continue
        account, _, ident = chunk.partition("=")
        account, ident = account.strip().lower(), ident.strip()
        if account and ident:
            out[account] = ident
    return out


def account_of(inst):
    """The login of the account an installation sits on. Pure."""
    if not isinstance(inst, dict):
        return None
    account = inst.get("account")
    if isinstance(account, dict) and account.get("login"):
        return str(account["login"])
    return None


def stable_key(inst):
    """The value worth keying stored state on. Pure.

    The login rather than the installation id, lowercased so the same account
    written two ways is one key. This is the whole recommendation of the note,
    expressed as a function so it can be tested rather than only asserted.
    """
    login = account_of(inst)
    return login.lower() if login else None


def index_by_id(installations):
    """Installations by their id, as text. Pure."""
    out = {}
    for inst in installations or []:
        if isinstance(inst, dict) and inst.get("id") is not None:
            out[str(inst["id"]).strip()] = inst
    return out


def index_by_account(installations):
    """Installations by lowercased account login. Pure."""
    out = {}
    for inst in installations or []:
        key = stable_key(inst)
        if key:
            out[key] = inst
    return out


def current_id_for(account, by_account):
    """The id this account's installation has right now, or None. Pure."""
    inst = (by_account or {}).get(str(account or "").strip().lower())
    return str(inst["id"]) if isinstance(inst, dict) and inst.get("id") is not None else None


def parse_moment(text):
    """An ISO 8601 timestamp as an aware datetime, or None. Pure."""
    raw = str(text or "").strip()
    if not raw:
        return None
    if raw.endswith("Z"):
        raw = raw[:-1] + "+00:00"
    try:
        when = datetime.fromisoformat(raw)
    except ValueError:
        return None
    return when if when.tzinfo else when.replace(tzinfo=timezone.utc)


def reinstalled_since(inst, recorded_at):
    """Whether this installation was created after the map was written. Pure.

    None when either date is unreadable, which is a third answer rather than a
    False: not knowing is not the same as knowing nothing happened.
    """
    created = parse_moment((inst or {}).get("created_at") if isinstance(inst, dict) else None)
    recorded = parse_moment(recorded_at)
    if created is None or recorded is None:
        return None
    return created > recorded


def drift(account, configured_id, by_id, by_account, recorded_at=None):
    """Compare one configured pair against reality. Pure."""
    account = str(account or "").strip()
    wanted = str(configured_id or "").strip()
    listed = (by_id or {}).get(wanted)
    current = current_id_for(account, by_account)

    if listed is not None:
        owner = account_of(listed) or ""
        if owner.lower() != account.lower():
            return ("crossed",
                    "%s is configured as %s, which exists and belongs to %s. "
                    "Nothing about this fails: it works against the wrong "
                    "account." % (account, wanted, owner or "another account"))
        fresh = reinstalled_since(listed, recorded_at)
        if fresh:
            return ("current-but-reinstalled",
                    "%s still resolves to %s, and that installation was created "
                    "after the map was written, so the App was removed and "
                    "re-added at some point." % (account, wanted))
        return ("current", "%s resolves to %s." % (account, wanted))

    if current is not None:
        created = (by_account.get(account.lower()) or {}).get("created_at")
        return ("stale",
                "%s is configured as %s, which this App no longer has. The "
                "current installation for %s is %s%s."
                % (account, wanted, account, current,
                   ", created %s" % created if created else ""))
    return ("gone",
            "%s is configured as %s and this App has no installation on that "
            "account at all. It was uninstalled and not put back."
            % (account, wanted))


def unmapped(by_account, configured):
    """Accounts the App is installed on that the configuration omits. Pure."""
    known = {str(k).strip().lower() for k in (configured or {})}
    return sorted(k for k in (by_account or {}) if k not in known)


def summarize(findings):
    """Counts by state, with the silent finding pulled out. Pure."""
    counts = {}
    for f in findings or []:
        counts[f["state"]] = counts.get(f["state"], 0) + 1
    return {"total": len(findings or []), "by_state": counts,
            "silent": sum(counts.get(s, 0) for s in SILENT)}


def repair(state, account=None, current=None):
    """The sentence a reader has to act on. Pure."""
    if state == "crossed":
        return ("stop the deploy. The id filed under %s belongs to another "
                "account, so every call made with it lands on the wrong "
                "organization and nothing will ever error. Fix the mapping, "
                "then resolve the id at runtime so it cannot drift again."
                % (account or "this account"))
    if state == "stale":
        return ("resolve the id per account from the org's own installation "
                "route rather than storing it. The current id is %s today and "
                "will be a different one after the next reinstall."
                % (current or "on the list above"))
    if state == "gone":
        return ("the App is not installed on %s. This is not an id problem: "
                "somebody has to install it again, and your code should key "
                "state on the account login so the history survives."
                % (account or "that account"))
    if state == "current-but-reinstalled":
        return ("nothing is broken, but the id changed hands once already. "
                "Move the lookup into the code before it changes again.")
    return "nothing. This account resolves correctly."


def get(session, path):
    """One GET. Returns (status, json-or-None)."""
    url = API + path if path.startswith("/") else path
    r = session.get(url, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = None
    return r.status_code, body


def list_installations(session, pages=10):
    """Every installation this App currently has. Read only."""
    out = []
    for page in range(1, pages + 1):
        status, body = get(session, "/app/installations?per_page=100&page=%d" % page)
        if status != 200 or not isinstance(body, list):
            if page == 1:
                log.error("GET /app/installations returned %d; this endpoint "
                          "wants the App's JWT", status)
            break
        out.extend(body)
        if len(body) < 100:
            break
    return out


def confirm_account(session, account):
    """The current installation for one organization, straight from the API."""
    status, body = get(session, "/orgs/%s/installation" % account)
    if status != 200 or not isinstance(body, dict):
        return None
    return str(body.get("id")) if body.get("id") is not None else None


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--map", default=os.environ.get("GITHUB_INSTALLATION_MAP", ""),
                    help="account=id pairs, comma separated, or a JSON object")
    ap.add_argument("--recorded-at", default=os.environ.get("GITHUB_MAP_RECORDED_AT"),
                    help="ISO date the map was last written, to spot reinstalls")
    ap.add_argument("--confirm", action="store_true",
                    help="also resolve each account's current id directly")
    args = ap.parse_args()

    jwt = os.environ.get("GITHUB_APP_JWT")
    if not jwt:
        log.error("set GITHUB_APP_JWT. The installation list is read with the "
                  "App's JWT, not with a token minted from an installation")
        return 2

    configured = parse_map(args.map)
    if not configured:
        log.error("no account=id pairs to check; pass --map or set "
                  "GITHUB_INSTALLATION_MAP")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + jwt,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    installations = list_installations(session)
    log.info("%d installation(s) visible to this App", len(installations))
    by_id = index_by_id(installations)
    by_account = index_by_account(installations)

    findings = []
    for account, ident in sorted(configured.items()):
        state, detail = drift(account, ident, by_id, by_account, args.recorded_at)
        row = {"account": account, "configured_id": ident, "state": state,
               "detail": detail, "current_id": current_id_for(account, by_account)}
        if args.confirm:
            row["confirmed_id"] = confirm_account(session, account)
        findings.append(row)

    findings.sort(key=lambda f: (f["state"] not in SILENT, f["account"]))
    for f in findings:
        if f["state"] != "current":
            log.info("%s: %s", f["state"], f["detail"])
            log.info("repair: %s", repair(f["state"], f["account"], f["current_id"]))

    extra = unmapped(by_account, configured)
    if extra:
        log.info("also installed and not in the map: %s", ", ".join(extra))

    stats = summarize(findings)
    print(json.dumps({"visible": len(installations), "summary": stats,
                      "unmapped_accounts": extra, "findings": findings},
                     indent=2, default=str))
    return 1 if stats["by_state"].get("current", 0) != stats["total"] else 0


if __name__ == "__main__":
    sys.exit(main())
github-installation-id-drift.mjs
/**
 * Find configured GitHub App installation ids that no longer mean what they did.
 *
 * Read only. One paginated GET over the App's own installations with the App
 * JWT, and one optional GET per configured account. The endpoint that mints an
 * installation access token is a write and is not called here.
 *
 * Environment:
 *   GITHUB_APP_JWT           the JWT your own signing code produced
 *   GITHUB_INSTALLATION_MAP  account=id pairs, comma separated, or a JSON object
 *   GITHUB_MAP_RECORDED_AT   optional ISO date the map was last written
 */
const API = 'https://api.github.com';
const UA = 'github-installation-id-drift/1.0';

/** The finding that never announces itself. */
export const SILENT = ['crossed'];

/** account=id pairs, or a JSON object, into a plain object. Pure. */
export function parseMap(text) {
  const raw = String(text ?? '').trim();
  if (!raw) return {};
  if (raw.startsWith('{')) {
    let loaded;
    try { loaded = JSON.parse(raw); } catch { return {}; }
    const out = {};
    for (const [k, v] of Object.entries(loaded || {})) {
      const key = String(k).trim().toLowerCase();
      if (key) out[key] = String(v).trim();
    }
    return out;
  }
  const out = {};
  for (const chunk of raw.replace(/;/g, ',').split(',')) {
    const at = chunk.indexOf('=');
    if (at < 0) continue;
    const account = chunk.slice(0, at).trim().toLowerCase();
    const ident = chunk.slice(at + 1).trim();
    if (account && ident) out[account] = ident;
  }
  return out;
}

/** The login of the account an installation sits on. Pure. */
export function accountOf(inst) {
  if (!inst || typeof inst !== 'object') return null;
  const account = inst.account;
  if (account && typeof account === 'object' && account.login) return String(account.login);
  return null;
}

/** The value worth keying stored state on. Pure. */
export function stableKey(inst) {
  const login = accountOf(inst);
  return login ? login.toLowerCase() : null;
}

/** Installations by their id, as text. Pure. */
export function indexById(installations) {
  const out = {};
  for (const inst of installations || []) {
    if (inst && typeof inst === 'object' && inst.id !== null && inst.id !== undefined) {
      out[String(inst.id).trim()] = inst;
    }
  }
  return out;
}

/** Installations by lowercased account login. Pure. */
export function indexByAccount(installations) {
  const out = {};
  for (const inst of installations || []) {
    const key = stableKey(inst);
    if (key) out[key] = inst;
  }
  return out;
}

/** The id this account's installation has right now, or null. Pure. */
export function currentIdFor(account, byAccount) {
  const inst = (byAccount || {})[String(account ?? '').trim().toLowerCase()];
  if (!inst || typeof inst !== 'object') return null;
  return inst.id === null || inst.id === undefined ? null : String(inst.id);
}

/** An ISO 8601 timestamp as epoch milliseconds, or null. Pure. */
export function parseMoment(text) {
  const raw = String(text ?? '').trim();
  if (!raw) return null;
  const ms = Date.parse(raw);
  return Number.isNaN(ms) ? null : ms;
}

/** Whether this installation was created after the map was written. Pure. */
export function reinstalledSince(inst, recordedAt) {
  const created = parseMoment(inst && typeof inst === 'object' ? inst.created_at : null);
  const recorded = parseMoment(recordedAt);
  if (created === null || recorded === null) return null;
  return created > recorded;
}

/** Compare one configured pair against reality. Pure. */
export function drift(account, configuredId, byId, byAccount, recordedAt = null) {
  const name = String(account ?? '').trim();
  const wanted = String(configuredId ?? '').trim();
  const listed = (byId || {})[wanted];
  const current = currentIdFor(name, byAccount);

  if (listed !== undefined && listed !== null) {
    const owner = accountOf(listed) || '';
    if (owner.toLowerCase() !== name.toLowerCase()) {
      return ['crossed',
        `${name} is configured as ${wanted}, which exists and belongs to `
        + `${owner || 'another account'}. Nothing about this fails: it works `
        + 'against the wrong account.'];
    }
    if (reinstalledSince(listed, recordedAt)) {
      return ['current-but-reinstalled',
        `${name} still resolves to ${wanted}, and that installation was created `
        + 'after the map was written, so the App was removed and re-added at '
        + 'some point.'];
    }
    return ['current', `${name} resolves to ${wanted}.`];
  }

  if (current !== null) {
    const created = ((byAccount || {})[name.toLowerCase()] || {}).created_at;
    return ['stale',
      `${name} is configured as ${wanted}, which this App no longer has. The `
      + `current installation for ${name} is ${current}`
      + `${created ? `, created ${created}` : ''}.`];
  }
  return ['gone',
    `${name} is configured as ${wanted} and this App has no installation on `
    + 'that account at all. It was uninstalled and not put back.'];
}

/** Accounts the App is installed on that the configuration omits. Pure. */
export function unmapped(byAccount, configured) {
  const known = new Set(Object.keys(configured || {}).map((k) => String(k).trim().toLowerCase()));
  return Object.keys(byAccount || {}).filter((k) => !known.has(k)).sort();
}

/** Counts by state, with the silent finding pulled out. Pure. */
export function summarize(findings) {
  const counts = {};
  for (const f of findings || []) counts[f.state] = (counts[f.state] || 0) + 1;
  return {
    total: (findings || []).length,
    by_state: counts,
    silent: SILENT.reduce((n, s) => n + (counts[s] || 0), 0),
  };
}

/** The sentence a reader has to act on. Pure. */
export function repair(state, account = null, current = null) {
  if (state === 'crossed') {
    return `stop the deploy. The id filed under ${account || 'this account'} `
      + 'belongs to another account, so every call made with it lands on the '
      + 'wrong organization and nothing will ever error. Fix the mapping, then '
      + 'resolve the id at runtime so it cannot drift again.';
  }
  if (state === 'stale') {
    return 'resolve the id per account from the org\'s own installation route '
      + `rather than storing it. The current id is ${current || 'on the list above'} `
      + 'today and will be a different one after the next reinstall.';
  }
  if (state === 'gone') {
    return `the App is not installed on ${account || 'that account'}. This is `
      + 'not an id problem: somebody has to install it again, and your code '
      + 'should key state on the account login so the history survives.';
  }
  if (state === 'current-but-reinstalled') {
    return 'nothing is broken, but the id changed hands once already. Move the '
      + 'lookup into the code before it changes again.';
  }
  return 'nothing. This account resolves correctly.';
}

function headers(jwt) {
  return {
    Authorization: `Bearer ${jwt}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
}

async function get(jwt, path) {
  const res = await fetch(API + path, { headers: headers(jwt) });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  return { status: res.status, body };
}

async function main() {
  const jwt = process.env.GITHUB_APP_JWT;
  if (!jwt) {
    console.error('set GITHUB_APP_JWT; the installation list wants the App JWT');
    process.exitCode = 2;
    return;
  }
  const configured = parseMap(process.env.GITHUB_INSTALLATION_MAP || '');
  if (!Object.keys(configured).length) {
    console.error('set GITHUB_INSTALLATION_MAP to account=id pairs');
    process.exitCode = 2;
    return;
  }
  const recordedAt = process.env.GITHUB_MAP_RECORDED_AT || null;

  const installations = [];
  for (let page = 1; page <= 10; page += 1) {
    const { status, body } = await get(jwt, `/app/installations?per_page=100&page=${page}`);
    if (status !== 200 || !Array.isArray(body)) {
      if (page === 1) console.error(`GET /app/installations returned ${status}`);
      break;
    }
    installations.push(...body);
    if (body.length < 100) break;
  }
  console.log(`${installations.length} installation(s) visible to this App`);

  const byId = indexById(installations);
  const byAccount = indexByAccount(installations);
  const findings = Object.entries(configured).sort().map(([account, ident]) => {
    const [state, detail] = drift(account, ident, byId, byAccount, recordedAt);
    return {
      account, configured_id: ident, state, detail,
      current_id: currentIdFor(account, byAccount),
    };
  });
  findings.sort((a, b) => (SILENT.includes(b.state) ? 1 : 0) - (SILENT.includes(a.state) ? 1 : 0)
    || a.account.localeCompare(b.account));

  for (const f of findings) {
    if (f.state !== 'current') {
      console.log(`${f.state}: ${f.detail}`);
      console.log(`repair: ${repair(f.state, f.account, f.current_id)}`);
    }
  }
  const extra = unmapped(byAccount, configured);
  if (extra.length) console.log(`also installed and not in the map: ${extra.join(', ')}`);

  const stats = summarize(findings);
  console.log(JSON.stringify({
    visible: installations.length, summary: stats, unmapped_accounts: extra, findings,
  }, null, 2));
  process.exitCode = (stats.by_state.current || 0) === stats.total ? 0 : 1;
}

// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The case the tests are built around is the one that produces no error: an id that exists, resolves and belongs to somebody else. It has to come back as its own state, it has to be ordered ahead of the noisy failures, and it must never be confused with the stale id it superficially resembles. The rest pin the input shapes this check meets in the wild — a map written as pairs and as JSON, ids stored as numbers and as strings, logins in the wrong case — and the third answer for a reinstall date that cannot be read.

test_github_installation_id_drift.py
from github_installation_id_drift import (
    account_of, current_id_for, drift, index_by_account, index_by_id, parse_map,
    reinstalled_since, repair, stable_key, summarize, unmapped,
)

ACME = {"id": 55120044, "account": {"login": "acme-corp"},
        "created_at": "2026-08-25T08:02:11Z"}
GAMMA = {"id": 41234568, "account": {"login": "gamma-labs"},
         "created_at": "2024-02-01T00:00:00Z"}
BY_ID = index_by_id([ACME, GAMMA])
BY_ACCOUNT = index_by_account([ACME, GAMMA])


def test_the_map_is_read_as_pairs_or_as_json():
    assert parse_map("acme-corp=41234567,beta-inc=41234568") == {
        "acme-corp": "41234567", "beta-inc": "41234568"}
    assert parse_map(' {"Acme-Corp": 41234567} ') == {"acme-corp": "41234567"}
    assert parse_map(" acme-corp = 41234567 ; beta-inc=9 ") == {
        "acme-corp": "41234567", "beta-inc": "9"}
    assert parse_map("") == {}
    assert parse_map("nonsense") == {}
    assert parse_map("{not json") == {}


def test_ids_are_indexed_as_text_however_they_arrived():
    assert BY_ID["55120044"] is ACME
    assert index_by_id([{"id": "77", "account": {"login": "x"}}])["77"]["id"] == "77"
    assert index_by_id([{"account": {"login": "x"}}]) == {}


def test_the_stable_key_is_the_login_not_the_id():
    assert stable_key(ACME) == "acme-corp"
    assert stable_key({"id": 5, "account": {"login": "Acme-Corp"}}) == "acme-corp"
    assert stable_key({"id": 5}) is None
    assert account_of(None) is None


def test_an_id_that_belongs_to_another_account_is_its_own_finding():
    state, detail = drift("beta-inc", 41234568, BY_ID, BY_ACCOUNT)
    assert state == "crossed"
    assert "gamma-labs" in detail
    assert "wrong account" in detail
    assert "stop the deploy" in repair(state, "beta-inc")


def test_a_missing_id_on_a_live_account_names_the_current_one():
    state, detail = drift("acme-corp", 41234567, BY_ID, BY_ACCOUNT)
    assert state == "stale"
    assert "55120044" in detail
    assert current_id_for("ACME-CORP", BY_ACCOUNT) == "55120044"


def test_a_missing_id_on_a_missing_account_is_not_a_stale_id():
    state, detail = drift("delta-ltd", 999, BY_ID, BY_ACCOUNT)
    assert state == "gone"
    assert "no installation on that account" in detail


def test_a_matching_id_is_current_whether_it_was_stored_as_text():
    assert drift("acme-corp", "55120044", BY_ID, BY_ACCOUNT)[0] == "current"
    assert drift("Acme-Corp", 55120044, BY_ID, BY_ACCOUNT)[0] == "current"


def test_a_reinstall_after_the_map_was_written_is_flagged_even_when_it_matches():
    state, detail = drift("acme-corp", 55120044, BY_ID, BY_ACCOUNT,
                          recorded_at="2026-01-01T00:00:00Z")
    assert state == "current-but-reinstalled"
    assert "removed and re-added" in detail


def test_an_unreadable_date_is_a_third_answer_and_not_a_no():
    assert reinstalled_since(ACME, "2026-01-01T00:00:00Z") is True
    assert reinstalled_since(ACME, "2026-12-01T00:00:00Z") is False
    assert reinstalled_since(ACME, None) is None
    assert reinstalled_since({}, "2026-01-01T00:00:00Z") is None


def test_installations_the_map_never_mentions_are_listed_separately():
    assert unmapped(BY_ACCOUNT, {"acme-corp": "1"}) == ["gamma-labs"]
    assert unmapped(BY_ACCOUNT, {"ACME-CORP": "1", "gamma-labs": "2"}) == []


def test_the_summary_counts_the_silent_finding_apart():
    stats = summarize([{"state": "crossed"}, {"state": "stale"}, {"state": "current"}])
    assert stats["total"] == 3
    assert stats["silent"] == 1
    assert stats["by_state"]["stale"] == 1
github-installation-id-drift.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  accountOf, currentIdFor, drift, indexByAccount, indexById, parseMap,
  reinstalledSince, repair, stableKey, summarize, unmapped,
} from './github-installation-id-drift.mjs';

const ACME = {
  id: 55120044, account: { login: 'acme-corp' }, created_at: '2026-08-25T08:02:11Z',
};
const GAMMA = {
  id: 41234568, account: { login: 'gamma-labs' }, created_at: '2024-02-01T00:00:00Z',
};
const BY_ID = indexById([ACME, GAMMA]);
const BY_ACCOUNT = indexByAccount([ACME, GAMMA]);

test('the map is read as pairs or as JSON', () => {
  assert.deepEqual(parseMap('acme-corp=41234567,beta-inc=41234568'),
    { 'acme-corp': '41234567', 'beta-inc': '41234568' });
  assert.deepEqual(parseMap(' {"Acme-Corp": 41234567} '), { 'acme-corp': '41234567' });
  assert.deepEqual(parseMap(' acme-corp = 41234567 ; beta-inc=9 '),
    { 'acme-corp': '41234567', 'beta-inc': '9' });
  assert.deepEqual(parseMap(''), {});
  assert.deepEqual(parseMap('nonsense'), {});
  assert.deepEqual(parseMap('{not json'), {});
});

test('ids are indexed as text however they arrived', () => {
  assert.equal(BY_ID['55120044'], ACME);
  assert.equal(indexById([{ id: '77', account: { login: 'x' } }])['77'].id, '77');
  assert.deepEqual(indexById([{ account: { login: 'x' } }]), {});
});

test('the stable key is the login not the id', () => {
  assert.equal(stableKey(ACME), 'acme-corp');
  assert.equal(stableKey({ id: 5, account: { login: 'Acme-Corp' } }), 'acme-corp');
  assert.equal(stableKey({ id: 5 }), null);
  assert.equal(accountOf(null), null);
});

test('an id that belongs to another account is its own finding', () => {
  const [state, detail] = drift('beta-inc', 41234568, BY_ID, BY_ACCOUNT);
  assert.equal(state, 'crossed');
  assert.match(detail, /gamma-labs/);
  assert.match(detail, /wrong account/);
  assert.match(repair(state, 'beta-inc'), /stop the deploy/);
});

test('a missing id on a live account names the current one', () => {
  const [state, detail] = drift('acme-corp', 41234567, BY_ID, BY_ACCOUNT);
  assert.equal(state, 'stale');
  assert.match(detail, /55120044/);
  assert.equal(currentIdFor('ACME-CORP', BY_ACCOUNT), '55120044');
});

test('a missing id on a missing account is not a stale id', () => {
  const [state, detail] = drift('delta-ltd', 999, BY_ID, BY_ACCOUNT);
  assert.equal(state, 'gone');
  assert.match(detail, /no installation on that account/);
});

test('a matching id is current whether it was stored as text', () => {
  assert.equal(drift('acme-corp', '55120044', BY_ID, BY_ACCOUNT)[0], 'current');
  assert.equal(drift('Acme-Corp', 55120044, BY_ID, BY_ACCOUNT)[0], 'current');
});

test('a reinstall after the map was written is flagged even when it matches', () => {
  const [state, detail] = drift('acme-corp', 55120044, BY_ID, BY_ACCOUNT,
    '2026-01-01T00:00:00Z');
  assert.equal(state, 'current-but-reinstalled');
  assert.match(detail, /removed and re-added/);
});

test('an unreadable date is a third answer and not a no', () => {
  assert.equal(reinstalledSince(ACME, '2026-01-01T00:00:00Z'), true);
  assert.equal(reinstalledSince(ACME, '2026-12-01T00:00:00Z'), false);
  assert.equal(reinstalledSince(ACME, null), null);
  assert.equal(reinstalledSince({}, '2026-01-01T00:00:00Z'), null);
});

test('installations the map never mentions are listed separately', () => {
  assert.deepEqual(unmapped(BY_ACCOUNT, { 'acme-corp': '1' }), ['gamma-labs']);
  assert.deepEqual(unmapped(BY_ACCOUNT, { 'ACME-CORP': '1', 'gamma-labs': '2' }), []);
});

test('the summary counts the silent finding apart', () => {
  const stats = summarize([{ state: 'crossed' }, { state: 'stale' }, { state: 'current' }]);
  assert.equal(stats.total, 3);
  assert.equal(stats.silent, 1);
  assert.equal(stats.by_state.stale, 1);
});

FAQ

Why does the installation id change at all?

Because an installation is a record of one act of installing, not a permanent link between an App and an account. Uninstalling deletes that record; installing again creates a new one, with a new id, a new creation time and a freshly accepted permission grant. Nothing is carried across, and nothing about the old id is reserved. It is the same shape of mistake as treating a session id as a user id: it works until the session ends.

Can the script just mint a token to see whether the id still works?

It could and it does not. Minting an installation access token is a write, and this section's scripts hold a credential that can reach your repositories, so none of them writes. It also would not answer the interesting question. A mint that succeeds tells you the id resolves; it does not tell you the account behind it is the one you meant, which is the failure that produces no error and does real damage. Listing the installations answers both at once.

What does a crossed id actually cost?

It depends entirely on what the integration does with the token. A read-only integration leaks the other organization's data into your logs, your cache and possibly your product. One that writes will comment, label, dispatch or push against repositories belonging to somebody who never asked for it. In neither case does anything error, so the only bound on how long it runs is how long it takes somebody to notice their repository behaving strangely.

We get the id from the webhook payload. Are we safe?

Largely, yes, and that is the pattern worth copying. Every App webhook delivery carries an installation object with the id on it, and it is correct by construction because GitHub used it to route the delivery. Two caveats: verify the signature before you trust anything in the payload, and remember that work not triggered by a webhook, such as a nightly reconciliation over every customer, still needs the id resolved from somewhere. That path is where the stored value usually survives.

How often should this run?

On every deploy, and on a schedule between deploys. It is one paginated GET with a JWT you already hold, so the cost is negligible against the class of problem it catches. Run it as a deploy gate for the crossed state specifically, because that finding has no other chance of being noticed, and as a report for the rest. Recording the date the mapping was written buys the reinstall warning as well, which arrives before anything breaks.

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.