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.

Exception — PR review approvals use a separate App, not 2109526 (#7478). The auto-review pipeline posts APPROVE / REQUEST_CHANGES / review comments under a dedicated least-privilege App, p24-reviewer (pull_requests:write + contents:read + issues:write only — no contents:write / secrets:write / administration:write), minted via scripts/gh-app-token --app reviewer. Do not reuse App 2109526 for reviews — isolating the review identity from 2109526’s broad grant is the entire reason that App exists. See reviewer-github-app.md. Everything below in this doc is about App 2109526, the general-purpose App for non-review runtimes.


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.