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.shOrchestrator: 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.jsStandalone 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.jsUnit 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-tokenMints 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

  1. decrypt-check the row before writing — confirms scheme/key still correct.
  2. After writing: decrypt-check again — confirms the round-trip (encrypt → DB → decrypt) is lossless, and that name/allowedDomains survived unchanged.
  3. 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}/test endpoint does not reliably work for httpHeaderAuth credentials (returned an unrelated Unrecognized node type error when tried here — do not rely on it as a pass/fail signal for this credential type).
  4. 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

  1. Get the credential ID and current scope from the workflow JSON (GET /api/v1/workflows/{id}, grep credentials blocks).
  2. 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.
  3. Copy rotate-n8n-github-brand-cred.sh, change CRED_ID and the gh-app-token permission list.
  4. 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.