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:
| Runtime | Doc | Covers |
|---|---|---|
| n8n Code node | n8n/github-app-token-n8n.md | JWT 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 Actions | docs/plans/plan-4245-gh-app-token-fleet-migration.md | composite-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.md | Next.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:writeonly — nocontents:write/secrets:write/administration:write), minted viascripts/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. Seereviewer-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:
-
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 needsworkflow_dispatch,gh run list, or similar Actions-API calls is not covered yet (status of the 2026-08-09permission-actions: writerequest forauto-fix-gh-actions.ymlis 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. -
Does the installation cover the target repo?
repository_selection: allon theradieuaccount 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 atgithub.com/settings/installations/142989418before 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). -
If both check out: this is a migration candidate, not a Tier 3 classification. Follow §Migration checklist below.
-
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 PAT | GitHub App installation token | |
|---|---|---|
| Lifetime | 90 days (or no expiry) | 1 hour |
| Rotation | Browser + 2FA, human required | API call, fully autonomous |
| Scope | Fixed at creation, broad (often full repo) | Per-App permission grant, narrower by design |
| Secret stored | The 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 exposure | Full scope, until manually revoked | Whatever 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:
| Key | Description |
|---|---|
GITHUB_APP_ID | 2109526 |
GITHUB_APP_PRIVATE_KEY_B64 | Base64-encoded RSA private key (PEM) |
GITHUB_APP_INSTALLATION_ID | 142989418 — 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();
}Option B — @octokit/auth-app (recommended for any real codebase)
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/restimport { 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
- 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.
- 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). - Port the mint logic inline — Option A or B above, wired into the same code path the classic PAT used to serve.
- 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.
- 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/tokensonce 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
2109526already grantscontents:write, which is exactly whatcommit.ts’sPUT contentscall needs (confirmed already live-used byapply-supabase-migrations.yml/deploy-*.ymlperdocs/plans/plan-4245-gh-app-token-fleet-migration.md). - Whether the installation covers
PROCEDURES_REPOspecifically 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.