Playbook: Auto-refreshing an n8n httpHeaderAuth credential from a GitHub App token
Status: ACTIVE (created 2026-08-12, #6220 follow-up)
What this covers
Eliminating a static, long-lived GitHub PAT stored as an n8n httpHeaderAuth credential by
having a cron job on the n8n host periodically overwrite the credential’s encrypted value in
n8n’s Postgres database with a freshly-minted, narrowly-scoped GitHub App installation token
(1h TTL). No workflow nodes change — they keep referencing the same credential ID.
Why this exists
n8n’s public REST API (/api/v1/credentials) has no endpoint to update an existing credential’s
stored data — only GET, POST /test, PUT /transfer (ownership), create, and delete. Deleting
and recreating a credential gets a new ID, which breaks every node referencing the old one by ID
(17 nodes, in the case that motivated this: github_pat_brand, id 1IjksfFg5EMoUo8C, in the
“Brand - Content Review” workflow). Direct database write is the only way to refresh a credential
value in place.
Precondition: reverse-engineer, don’t guess, the encryption scheme
n8n (2.26.3, n8n-core) encrypts credential data with a legacy OpenSSL “Salted__” scheme
(packages/core/src/encryption/aes-256-cbc.ts): AES-256-CBC, key+IV derived from
N8N_ENCRYPTION_KEY via two rounds of MD5 (classic EVP_BytesToKey). Confirmed by reading the
actual compiled module inside the running container — do not hand-write this from memory; verify
against the live version first (docker exec <n8n-container> n8n --version, then locate
node_modules/.../n8n-core/dist/encryption/aes-256-cbc.js).
Reimplementation: scripts/cron-jobs/n8n_cred_cipher.js (standalone Node, only needs crypto).
Before any write: run decrypt-check against the current value and confirm it parses to
valid JSON with the expected shape (name, value, allowedDomains for httpHeaderAuth) —
this validates the scheme/key are correct without ever printing the secret. Only decrypt-then-
replace-the-value-field-then-re-encrypt (encrypt-preserve mode) — never rebuild the JSON
payload from scratch (lesson from #174: a from-scratch rebuild silently drops fields you didn’t
know existed).
The pieces
| File (on bms-4) | Purpose |
|---|---|
/opt/p24-infra/scripts/cron-jobs/rotate-n8n-github-brand-cred.sh | Orchestrator: mint → docker cp cipher script into the n8n container → decrypt-preserve-reencrypt → UPDATE credentials_entity. On any failure notifies per the Error Notification Standard (Discord embed + bug GH issue). |
/opt/p24-infra/scripts/cron-jobs/n8n_cred_cipher.js | Standalone cipher (see above); docker cp-ed into the n8n container’s /tmp on every run by the orchestrator — container restarts wipe /tmp, so it is never assumed to persist. Exports its functions so it is unit-testable. |
scripts/cron-jobs/n8n_cred_cipher.test.js | Unit tests (node --test scripts/cron-jobs/, zero deps): a known-answer vector produced independently by the OpenSSL CLI (proves the reimplementation matches OpenSSL’s real scheme, not just itself), round-trip losslessness, the Salted__ header, and that encrypt-preserve keeps unknown fields (the #174 regression). |
/etc/cron.d/rotate-n8n-github-brand-cred | */45 * * * * — comfortably inside the 1h token TTL |
/opt/p24-infra/scripts/gh-app-token | Mints the scoped token: --repos <repo> --permissions <perm:level,...> |
Error notification (mandatory for this cron). Because a silent failure lets the token expire
(after ~1h the credential is dead and every consuming node 401s), the orchestrator wires the Error
Notification Standard: any failed stage — mint, DB read, docker cp, encrypt, or UPDATE — fires a
Discord embed via P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL and opens a bug-labelled GH issue
in radieu/p24-infra. Both channels are best-effort (|| true) so a notifier outage never masks the
original failure. The failure message only ever carries the stage name — never the token value.
Verification after any change to this pattern
decrypt-checkthe row before writing — confirms scheme/key still correct.- After writing:
decrypt-checkagain — confirms the round-trip (encrypt → DB → decrypt) is lossless, and thatname/allowedDomainssurvived unchanged. - Independently mint a second token with the same scope and hit a real, safe (read-only or
idempotent) GitHub API endpoint directly (not through n8n) — e.g.
GET /repos/{owner}/{repo}/collaborators/{user}/permission— to confirm the App’s permissions are actually correct, since n8n’s own/credentials/{id}/testendpoint does not reliably work forhttpHeaderAuthcredentials (returned an unrelatedUnrecognized node typeerror when tried here — do not rely on it as a pass/fail signal for this credential type). - Manually run the exact cron command line once (
bash -c '...'copy-pasted from/etc/cron.d/...) before trusting the schedule — catches quoting bugs the crontab’s own syntax doesn’t surface until the next scheduled fire.
Applying this to another n8n credential
- Get the credential ID and current scope from the workflow JSON (
GET /api/v1/workflows/{id}, grepcredentialsblocks). - Determine the minimum GitHub App permissions the consuming nodes actually need — do not default to broad scopes just because the static PAT it replaces had them.
- Copy
rotate-n8n-github-brand-cred.sh, changeCRED_IDand thegh-app-tokenpermission list. - Follow the verification steps above before wiring a new cron entry.
Known gap / accepted scope limit
This pattern only covers credentials n8n itself consumes. A script running outside n8n that
reads a static PAT directly from .env.local (e.g. scripts/content-review/create-issue.py’s
GITHUB_PAT_ALL_WRITES) is a separate problem — it cannot call gh-app-token itself (that
requires the App private key, which stays inside the p24-infra security boundary) and needs either
routing through n8n (webhook trigger → n8n does the GitHub call) or a narrowly-scoped, still
manually-rotated PAT. Not solved by this playbook.