Playbook: GitHub App Short-Lived Tokens — Standard Mechanism for Any Runtime

GitHub App: n8n.io pull request flow App ID: 2109526 Installation ID: 142989418 (“radieu account, all repos”, repository_selection: all) Status: canonical mechanism to prefer over a classic GitHub PAT for any new consumer, and the first thing to check before classifying an existing classic-PAT consumer as Tier 3 in secret-rotation-access-matrix.md Origin: written as a follow-up from issue #5924 (see §Worked example below)


Same App, different runtime — sibling docs

This App is already integrated in three places. Each doc below covers one runtime’s specific plumbing; this doc is the generalized version for everything else (application server code, scripts, any Node/TS backend). Read the sibling that matches your consumer first if one exists — come back here only if your consumer doesn’t fit any of them:

RuntimeDocCovers
n8n Code noden8n/github-app-token-n8n.mdJWT mint via RS256 in a Code node, sub-workflow pattern, migrating n8n workflows off PATs
bms-4 worker (bash)secret-rotation-access-matrix.md §Tier 1 → “Deploy-key autonomy via GitHub App”bin/gh-app-token.sh, git-credential-gh-token.sh, gh_app_secret_set.sh — git clone/push and gh secret set for the autonomous worker
GitHub Actionsdocs/plans/plan-4245-gh-app-token-fleet-migration.mdcomposite-action Generate GitHub App token step used by apply-supabase-migrations.yml / deploy-*.yml / auto-fix-gh-actions.yml
Application server code (this doc)github-app-token-integration.mdNext.js/Vercel API routes, standalone Node/TS backends, any process that wants to mint its own installation token inline instead of depending on a long-lived classic PAT

All four share the same App ID, the same JWT→installation-token exchange, and the same “never persist the token” rule. Only the surrounding plumbing (where the private key lives, how the mint step is invoked) differs per runtime.


When to use this instead of accepting Tier 3

Any time a consumer — app backend code, a script, a CI job — uses a classic GitHub PAT purely because “there’s no create-via-API path for a classic PAT” (the standard Tier 3 justification in the access matrix), check this App first before writing that classification down:

  1. Does the App’s existing permission grant cover what the consumer needs? Current grant (§2.4 audit, 2026-07-20, updated 2026-08-09 — see secret-rotation-access-matrix.md §Tier 1 → “Deploy-key autonomy via GitHub App”): issues:write, secrets:write, contents:write, metadata:read, pull_requests:write, administration:write, workflows:write (added 2026-08-09, #5841 follow-up). Not granted: actions:write — a consumer that needs workflow_dispatch, gh run list, or similar Actions-API calls is not covered yet (status of the 2026-08-09 permission-actions: write request for auto-fix-gh-actions.yml is unverified as closed — treat it as “still missing” until a live mint confirms otherwise). If the permission genuinely isn’t covered, widening the App manifest is itself a Tier 3 human action (github.com/settings/apps/2109526/permissions) — but it’s a one-time action that unblocks every future consumer needing that permission, not a per-rotation burden.

  2. Does the installation cover the target repo? repository_selection: all on the radieu account covers every repo owned by that account. If the target repo lives in a different account/org, or is a fork/external repo, the installation does not automatically cover it — confirm at github.com/settings/installations/142989418 before assuming coverage. If it’s not covered, adding the repo to the installation is a one-time human action in the GitHub UI (Tier 3, but again one-time, not per-rotation).

  3. If both check out: this is a migration candidate, not a Tier 3 classification. Follow §Migration checklist below.

  4. If either check fails and can’t be resolved: the classic PAT genuinely has no better alternative today — Tier 3 stands, but note why the App doesn’t cover it (missing permission vs. repo not in installation) so the next person doesn’t re-derive the same investigation.


Why this is better than a classic PAT

Classic PATGitHub App installation token
Lifetime90 days (or no expiry)1 hour
RotationBrowser + 2FA, human requiredAPI call, fully autonomous
ScopeFixed at creation, broad (often full repo)Per-App permission grant, narrower by design
Secret storedThe token itself (bearer credential, works until revoked/expired)An RSA private key (used only to mint short-lived tokens — a stolen token is dead in ≤1h)
Blast radius of exposureFull scope, until manually revokedWhatever was minted expires within the hour regardless of revocation action
Rotation tier (access matrix)Tier 2 (Playwright, prerequisite-gated) or Tier 3 (no automation path)Tier 1 — private key itself rotates via App UI (Tier 3, but rare/one-time), day-to-day token issuance is fully autonomous

Secrets needed

Same three keys as every other runtime integration:

KeyDescription
GITHUB_APP_ID2109526
GITHUB_APP_PRIVATE_KEY_B64Base64-encoded RSA private key (PEM)
GITHUB_APP_INSTALLATION_ID142989418 — radieu account installation

These do not yet exist in every consumer’s SOPS file — only secrets/n8n-bms4.env.sops has them today. Adding them to a new consumer’s SOPS file (e.g. secrets/et-operational-platform.env.sops) is a secret-manager operation, separate from and prerequisite to the code change described here. Class A (p24-infra) sessions: delegate via the secret-manager role per CLAUDE.md §Role Enforcement. Class B (any other repo’s session): file a [SECRET-REQUEST] issue per docs/playbooks/secret-manager-request.md. Either way, request the same 3 keys, same values (this is one App/one private key shared fleet-wide — do not mint a second App unless there’s a specific reason to isolate permissions further).


Node/TypeScript reference implementation (application server code)

Two options, depending on how much you want to own.

Option A — zero-dependency, mirrors the n8n Code-node logic exactly

Use this when you want no new npm dependency, or you’re porting the n8n logic 1:1 into a Next.js API route / Node backend. This is the same algorithm as n8n/github-app-token-n8n.md §Token generation internals, adapted to run outside n8n’s Code-node sandbox (using fetch instead of raw https, and reading env vars the normal Node way):

// lib/github-app-token.ts
import crypto from "node:crypto";
 
interface InstallationTokenResponse {
  token: string;
  expires_at: string;
}
 
/**
 * Mints a fresh GitHub App installation token. Call this immediately before use —
 * do NOT cache or persist the returned token. Each call produces a token valid for 1h;
 * callers should just call this again on the next request/invocation.
 */
export async function getGitHubAppInstallationToken(): Promise<string> {
  const appId = process.env.GITHUB_APP_ID;
  const privateKeyB64 = process.env.GITHUB_APP_PRIVATE_KEY_B64;
  const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
 
  if (!appId || !privateKeyB64 || !installationId) {
    throw new Error(
      "Missing GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY_B64 / GITHUB_APP_INSTALLATION_ID env vars"
    );
  }
 
  const pem = Buffer.from(privateKeyB64, "base64").toString("utf8");
 
  // Build RS256 JWT — 5-minute window, -10s iat for clock-skew tolerance (same as n8n playbook)
  const now = Math.floor(Date.now() / 1000);
  const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
  const payload = Buffer.from(
    JSON.stringify({ iat: now - 10, exp: now + 300, iss: appId })
  ).toString("base64url");
  const signingInput = `${header}.${payload}`;
  const signature = crypto.sign("RSA-SHA256", Buffer.from(signingInput), pem).toString("base64url");
  const jwt = `${signingInput}.${signature}`;
 
  // Exchange the JWT for a 1-hour installation token
  const res = await fetch(
    `https://api.github.com/app/installations/${installationId}/access_tokens`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${jwt}`,
        Accept: "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
      },
    }
  );
 
  if (!res.ok) {
    throw new Error(`GitHub App token exchange failed: ${res.status} ${await res.text()}`);
  }
 
  const data = (await res.json()) as InstallationTokenResponse;
  return data.token; // "ghs_..." — do not log, do not store
}

Usage in an API route (e.g. the commit.ts pattern from #5924 — a Contents API PUT):

import { getGitHubAppInstallationToken } from "@/lib/github-app-token";
 
export async function commitFileToRepo(path: string, content: string, message: string) {
  const token = await getGitHubAppInstallationToken(); // fresh token, this call only
 
  const res = await fetch(
    `https://api.github.com/repos/${process.env.PROCEDURES_REPO}/contents/${path}`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
      },
      body: JSON.stringify({
        message,
        content: Buffer.from(content, "utf8").toString("base64"),
      }),
    }
  );
 
  if (!res.ok) {
    throw new Error(`GitHub commit failed: ${res.status} ${await res.text()}`);
  }
  return res.json();
}

If the project already pulls in npm dependencies freely (most app-server codebases do), prefer the official library over hand-rolled JWT signing — it handles clock-skew, token caching within its own safe TTL margin, and retries, and is maintained by GitHub itself:

npm install @octokit/auth-app @octokit/rest
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
 
const auth = createAppAuth({
  appId: process.env.GITHUB_APP_ID!,
  privateKey: Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY_B64!, "base64").toString("utf8"),
  installationId: process.env.GITHUB_APP_INSTALLATION_ID!,
});
 
const octokit = new Octokit({ authStrategy: createAppAuth, auth: {
  appId: process.env.GITHUB_APP_ID!,
  privateKey: Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY_B64!, "base64").toString("utf8"),
  installationId: process.env.GITHUB_APP_INSTALLATION_ID!,
} });
 
// octokit.rest.repos.createOrUpdateFileContents(...) etc. — auth is handled per-request,
// @octokit/auth-app internally mints/caches the installation token and refreshes it near expiry.

Either option is fine — Option A has no new dependency and is easiest to audit line-by-line (useful when a reviewer wants to see exactly what’s happening with the private key); Option B is less code to maintain and is what GitHub itself recommends for new integrations.

Rule that applies to both options — never persist the token

Same rule as every other runtime integration in this fleet: the installation token is valid for 1 hour. Do not write it to a database, cache it in Redis, put it in a long-lived in-memory singleton across requests, or log it. Mint fresh per request/invocation (Option A) or let @octokit/auth-app manage its own short-lived internal cache (Option B) — never build your own longer-lived cache on top of either.


Migration checklist for an existing classic-PAT consumer

  1. Confirm coverage — App permission grant covers what the consumer needs, and the installation covers the target repo (see §When to use this above). Do this check before requesting new SOPS keys — no point provisioning credentials for a migration that turns out to be blocked on a missing permission.
  2. Add the 3 SOPS keys to the consumer’s own SOPS file (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY_B64, GITHUB_APP_INSTALLATION_ID) — this is a secret-manager operation, not part of this doc’s scope. Distribute via that consumer’s existing sync path (e.g. secrets-sync.yml → Vercel for a Vercel-hosted app).
  3. Port the mint logic inline — Option A or B above, wired into the same code path the classic PAT used to serve.
  4. Verify end-to-end — exercise the actual operation (e.g. a real commit to a test file/branch) using the new App-token path before touching the old PAT.
  5. Revoke the old classic PAT outright — do not rotate it to a new classic PAT “just in case.” Rotating in place re-creates the exact Tier-3 human-rotation burden this migration exists to remove. Revoke it at github.com/settings/tokens once step 4 is confirmed working, and remove the now-unused key from the consumer’s SOPS file in the same PR.

Worked example: et-operational-platform’s commit.ts (#5924)

Consumer: src/pages/api/admin/procedures/commit.ts in et-operational-platform — reads process.env.GITHUB_TOKEN server-side and PUTs file contents to a GitHub repo (PROCEDURES_REPO) via the Contents API, to commit multi-language procedure content edits.

What happened: an exposure incident (#5923, parent) surfaced this consumer’s classic PAT. The rotation request (#5924) initially classified it Tier 3 (STILL-HUMAN) per the access matrix, since a classic PAT has no create-via-API path. A follow-up comment on #5924 ran the feasibility check documented in this playbook and found:

  • App 2109526 already grants contents:write, which is exactly what commit.ts’s PUT contents call needs (confirmed already live-used by apply-supabase-migrations.yml / deploy-*.yml per docs/plans/plan-4245-gh-app-token-fleet-migration.md).
  • Whether the installation covers PROCEDURES_REPO specifically was not confirmed at investigation time (the repo is only referenced via an env var; same-org vs. external was not established) — this is the kind of check §1 of this playbook now asks for explicitly.

What was done in #5924 itself: the immediate incident response went the Tier-3 human route as originally planned — rotate the classic PAT same-day (steps 1-4 of the original request body), because blocking an active-exposure incident on building a migration would have been the wrong tradeoff. The App-token migration was explicitly not done in #5924 — it was filed as a tracked follow-up rather than riding along on the incident response, and this playbook is the generalized output of that follow-up (the code-level migration for commit.ts itself is still open work, to be done via the checklist above once someone picks it up).

Lesson generalized here: the Tier-3 classification in the access matrix was correct in the moment (no create-via-API path for the classic PAT itself), but incomplete — it didn’t ask “does the App we already run cover this?” before writing STILL-HUMAN. That question is now §1 of this doc, and the access matrix’s Tier 3 section links here so future classifications ask it first.


Operator binary drift — auto-pinned by secrets-sync.yml (#6109)

/usr/local/bin/gh-app-token is the operator-facing wrapper (invoked bare, e.g. gh-app-token --check). It was previously deployed by hand with no automation keeping it current, and drifted fleet-wide to a stale pre-argparse build that silently ignored --check and printed a live 1h App installation token to stdout unconditionally (#6096 — worst on bms-4, which carries the App creds).

secrets-sync.yml now closes this for good: sync-bms-4 and sync-vps-i1 each ship the committed scripts/gh-app-token from the CI runner’s fresh checkout and install -m0755 -o root -g root it into /usr/local/bin/gh-app-token on every sync, so the binary can never diverge from the repo source again. CI runs with root SSH, so this also remediates bms-4 automatically. Each job logs the installed sha256 (gh-app-token pinned: <sha>) — expect it to match sha256sum scripts/gh-app-token.

dev-laptop is not covered by CI (reverse-tunnel only, no public IP, no sync-* job) — it was remediated manually in #6103 and must be re-pinned by hand if it ever drifts; there is no automated push path to it.

Troubleshooting

Same failure modes as the n8n integration — see n8n/github-app-token-n8n.md §Troubleshooting for the full table (Missing GITHUB_APP env vars, JWT clock skew → 401, permission gaps → 403, App-not-installed-on-repo → 404). The causes and fixes are identical regardless of runtime.