Playbook: GitHub App Installation Token for n8n

GitHub App: n8n.io pull request flow
App ID: 2109526
Installation ID: 142989418 (radieu account, all repos)
n8n sub-workflow: GitHub App Token Generator (ID 6b7m52mpur3tXO1G)
Implemented: 2026-06-27 — replaces PAT-based GitHub auth for new n8n workflows


Why this exists

GitHub Personal Access Tokens (PATs) require browser + 2FA to create or rotate. The GitHub App approach stores an RSA private key in SOPS and generates short-lived installation tokens (1h TTL) on demand — no browser, no 2FA, fully autonomous rotation.

PATGitHub App token
Lifetime90 days1 hour
RotationBrowser + 2FAAPI call (autonomous)
ScopeFixed at creationPer-request scoping possible
Secret storedToken valueRSA private key

Secrets (in secrets/n8n-bms4.env.sops)

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

These are deployed to bms-4’s n8n container via secrets-sync.yml on merge to dev. n8n Code nodes read them via process.env.GITHUB_APP_*.


Using the sub-workflow in n8n

Call the GitHub App Token Generator sub-workflow (ID 6b7m52mpur3tXO1G) at the start of any workflow that needs GitHub API access:

Step 1 — Add “Execute Sub-workflow” node

Node type: Execute Sub-workflow
Sub-workflow: GitHub App Token Generator (6b7m52mpur3tXO1G)

Step 2 — Use the token in HTTP Request nodes

The sub-workflow returns { token: "ghs_...", expires_at: "2026-06-27T20:17:01Z" }.

In subsequent HTTP Request nodes:

URL: https://api.github.com/repos/radieu/p24-infra/issues
Method: POST
Authentication: Header Auth (inline)
Header name: Authorization
Header value: Bearer {{ $('GitHub App Token Generator').item.json.token }}

Add header: X-GitHub-Api-Version: 2022-11-28

Step 3 — Do NOT store the token

Tokens expire in 1 hour. Each workflow execution calls the sub-workflow to get a fresh one. Never store the token value in n8n credentials or environment variables.


Token generation internals (Code node logic)

const crypto = require('crypto');
const https = require('https');
 
// Reads from bms-4 environment (deployed from SOPS)
const appId = process.env.GITHUB_APP_ID;
const pem = Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY_B64, 'base64').toString('utf8');
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
 
// Build RS256 JWT (5-minute window)
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 si = `${header}.${payload}`;
const sign = crypto.createSign('RSA-SHA256');
sign.update(si);
const jwt = `${si}.${sign.sign(pem, 'base64url')}`;
 
// Exchange JWT for 1-hour installation token
// POST /app/installations/{id}/access_tokens → { token: "ghs_...", expires_at: "..." }

Rotating the private key

  1. Go to https://github.com/settings/apps/2109526/installations → Edit
  2. Scroll to “Private keys” → Generate a private key (downloads new .pem)
  3. Save to D:\downloads\ (or any temp location)
  4. Run:
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$pemBytes = [System.IO.File]::ReadAllBytes("D:\downloads\<new-key>.pem")
$env:NEW_KEY_B64 = [Convert]::ToBase64String($pemBytes)
 
$plain = sops --decrypt --input-type dotenv --output-type dotenv d:\code_2026\p24-infra\secrets\n8n-bms4.env.sops
$updated = ($plain -split "`n" | Where-Object { $_ -notmatch "^GITHUB_APP_PRIVATE_KEY_B64=" -and $_ -ne "" })
$updated += "GITHUB_APP_PRIVATE_KEY_B64=$env:NEW_KEY_B64"
$env:NEW_KEY_B64 = ""
 
$temp = "d:\code_2026\p24-infra\secrets\n8n-bms4-edit.env.sops"
[System.IO.File]::WriteAllText($temp, ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv $temp
[System.IO.File]::WriteAllText("d:\code_2026\p24-infra\secrets\n8n-bms4.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
sops --decrypt --input-type dotenv --output-type dotenv d:\code_2026\p24-infra\secrets\n8n-bms4.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt" }
[System.IO.File]::Delete($temp)
  1. Delete the old key from GitHub App settings (can now have multiple keys during rotation)
  2. Commit + PR to main
  3. secrets-sync.yml auto-deploys to bms-4 on merge

Migrated workflows (as of 2026-06-27)

The following workflows were migrated from GitHub PAT - p24-infra credential to App tokens:

WorkflowIDMigrated
alertmanager-to-incidents-v3HGT5EAXGhsxdSSkk2026-06-27
resource-incident-analysisDL0Q9CUdn38vkiqx2026-06-27

Both now have a Get GitHub Token (Execute Sub-workflow) node wired before any GitHub API call. The GitHub PAT - p24-infra n8n credential (GwnEOfJI3qXM02nb) and the underlying PAT (CLAUDE_RUNNER_WORKER_GH_TOKEN on github.com/settings/tokens) can now be safely deleted.

Migrating additional n8n workflows from PAT to App tokens

For any future workflow that needs GitHub API access:

  1. Add “Execute Sub-workflow” node → GitHub App Token Generator (6b7m52mpur3tXO1G) before the GitHub call
  2. In GitHub HTTP Request nodes — set headers inline (no credential):
    • Authorization: ={{ 'Bearer ' + $('Get GitHub Token').item.json.token }}
    • Accept: application/vnd.github+json
    • X-GitHub-Api-Version: 2022-11-28
  3. Do NOT store the token — each execution gets a fresh 1h token

For existing workflows still using GITHUB_PAT_ALL_WRITES (github-token-clasic-full-access): migrate before revoking that token. The pattern is identical to the two workflows above.


Troubleshooting

SymptomCauseFix
Missing GITHUB_APP env varsSOPS not deployed to bms-4Merge PR to main → secrets-sync.yml runs
GitHub API 401JWT clock skewThe iat: now-10 already accounts for drift; if still failing, restart n8n container
GitHub API 403App lacks permissionCheck App permissions at github.com/settings/apps/2109526
GitHub API 404 on repo operationApp not installed or repo not coveredCheck github.com/settings/installations/142989418