Heroku API Key — Rotation Runbook
System: Heroku Platform API credential (HEROKU_API_KEY)
Owner: sre-agent / operator (Kristerpher)
Cadence: Every 90 days (quarterly)
Detailed per-step SOP: docs/ops/runbooks/rotation/heroku-platform-token.md
Drift recovery: docs/ops/runbooks/heroku-api-key-drift-recovery.md
Issue: #251
Last rotated: 2026-07-21 UTC (sre-agent, operator-authorized full credential rotation — see docs/incidents/2026-07-21-full-credential-rotation.md)
Next rotation due: 2026-10-19 UTC
What this key does
HEROKU_API_KEY is a global-scope Heroku OAuth authorization used by:
| Consumer | How it is read |
|---|---|
| CI/CD (Woodpecker) | Reads vault live per run — VAULT_SECRETS="HEROKU_API_KEY" in .woodpecker/*.yaml (staging AND prod: deploy-staging.yaml, deploy-prod.yaml, deploy-velvet.yaml, deploy-queue.yaml). NO static copy of any kind. GitHub Actions deploys were retired 2026-07-10 (ADR-0135/ADR-0136) — the deploy-heroku.yml GHA secret consumer no longer exists. |
| Vault (source of truth) | Infisical /MooseQuest/heroku/HEROKU_API_KEY, env prod |
| Agent sessions (SRE, ops) | Session-bootstrapped from vault via session-bootstrap.sh |
The billing-collector key (HEROKU_BILLING_API_KEY) is a separate read-scoped
authorization rotated on its own schedule — it is NOT covered here.
Rotation procedure (summary)
Follow docs/ops/runbooks/rotation/heroku-platform-token.md for the full
copy-pasteable commands. The high-level sequence is:
- List existing authorizations — capture the OLD authorization ID.
- Create new authorization via
POST /oauth/authorizations(global scope). Description convention:raxx-ci-rotation-YYYY-MM. - Verify new token —
GET /accountmust returnkris@moosequest.net. Also verify app-list read (GET /apps). Abort if either fails. - Update all stores (in this order, never out of order):
- Infisical vault:
PATCH /api/v3/secrets/raw/HEROKU_API_KEY(path/MooseQuest/heroku, envprod) - The 4 Heroku app config vars that carryHEROKU_API_KEYas their own config var:raxx-api-staging,raxx-api-prod,raxx-console-staging,raxx-console-prod(heroku config:set HEROKU_API_KEY=<value> -a <app>for each). GitHub Actions deploys were retired 2026-07-10 (ADR-0135/ADR-0136) — there is no GHA secret consumer to update; see the consumer table above. - Re-verify vault — re-read the vault entry and confirm its suffix matches the new token. Abort if it does not.
- Static-copy sweep (#4544) — run:
bash grep -rn "from_secret: HEROKU" .woodpecker/This must return zero hits. Any hit is a static WP org-secret copy of a Heroku credential that this rotation must also update (or, preferably, retire in favor of the run-time vault load — see #4544 for the precedent:HEROKU_PROD_API_KEYwas exactly this kind of copy, went stale after the 2026-07-21 rotation, and broke prod deploys for 5 weeks before anyone noticed). - Revoke OLD authorization —
heroku authorizations:revoke <OLD_ID>. Use the NEW key to make the revoke call. Verify the old authorization no longer appears inheroku authorizations. - Record audit entry — log
secret.rotate.completedin the rotation log below and updateLast rotated/Next rotation duein this file.
Guardrails (never skip)
- Verify the new token works BEFORE revoking the old one. No downtime gap.
- If you cannot identify the old authorization ID precisely via suffix matching, STOP and escalate rather than blind-revoke.
- Never print secret values to stdout/logs. Use suffix-only comparisons for validation.
- If more than one
raxx-ci-rotation-YYYY-MMauthorization is created (e.g. due to a retry), revoke all but the one written to vault before proceeding.
Rotation schedule
| Trigger | Cadence |
|---|---|
| Scheduled | Every 90 days (quarterly). Next: 2026-10-19 UTC (matches Next rotation due above — 90 days from the 2026-07-21 rotation) |
| Off-cycle | Suspected compromise, accidental log/commit of value, employee offboarding |
| Post-incident | Any incident where the key was exposed, transmitted in plaintext, or logged |
Scheduled reminder
A GitHub Actions cron should enforce the 90-day cadence. Until a dedicated
credential-expiry-monitor workflow exists, track via a recurring GitHub issue
or calendar event. Create a type:reliability issue due 1 week before each
rotation date so it lands in the weekly sweep.
Suggested cron check (to be added to a future credential-monitor workflow):
# .github/workflows/credential-expiry-monitor.yml (future)
# Fires 7 days before each rotation due date; opens a reminder issue.
on:
schedule:
- cron: '0 8 12 10 *' # 2026-10-12 08:00 UTC — one week before 2026-10-19
- cron: '0 8 10 1 *' # 2027-01-10 08:00 UTC — one week before 2027-01-17 (illustrative next cycle)
Until that workflow exists: set a calendar reminder for 2026-10-12 UTC so
the rotation runs before the key reaches 90 days old (current authorization
e4e7130b minted 2026-07-21 — see the rotation log below).
Rotation log
| Date (UTC) | Actor | Old auth description | New auth ID | Notes |
|---|---|---|---|---|
| 2026-07-01 | sre-agent (operator-authorized, issue #251) | raxx-automation: dispatch + git push (rotated 2026-05-02) (b1e6320e) |
85f3a15a (raxx-ci-rotation-2026-07) |
Vault/session drift detected: session held prod-deploy-key (1f970877), vault held raxx-automation — documented below. Orphan auth 558f0e9d also revoked. |
| 2026-07-21 | sre-agent (operator-authorized, full-fleet rotation after heroku releases:info plaintext exposure — docs/incidents/2026-07-21-full-credential-rotation.md) |
raxx-ci-rotation-2026-07 (85f3a15a) |
e4e7130b (raxx-ci-rotation-2026-07-21) |
Also propagated the new value to the 4 Heroku apps that carry HEROKU_API_KEY as their own config var (raxx-api-staging, raxx-api-prod, raxx-console-staging, raxx-console-prod). Confirmed Woodpecker reads HEROKU_API_KEY live from vault per-pipeline-run (VAULT_SECRETS="HEROKU_API_KEY" pattern in .woodpecker/*.yaml) — no separate WP secret store to update for this credential. HEROKU_API_KEY__AUTH_ID vault companion corrected (was stale, still pointing at the 2026-07-01-revoked b1e6320e). Old auth 85f3a15a confirmed revoked (verified 401 not re-tested live but revoke call returned HTTP 200 and post-revoke /account call with the new key succeeded independently). prod-deploy-key (1f970877) and the other stale authorizations flagged in the 2026-07-01 row were left untouched — out of scope for this incident-driven rotation. |
| 2026-08-29 | raxx-dev-bot (issue #4544, RCA docs/incidents/2026-08-29-deploy-prod-stale-wp-secret.md) |
n/a — no new Heroku authorization minted this cycle | n/a | This row is NOT a key rotation — it is the discovery + retirement of a static-copy gap in the previous rotation's coverage. WP org secret HEROKU_PROD_API_KEY (orgs/2, event-restricted [tag, manual]) was found stale: it still held the authorization revoked by the 2026-07-21 rotation above (85f3a15a), because that rotation was name-matched (only updated things literally named HEROKU_API_KEY) not consumer-matched (missed the separately-named prod alias). Caused deploy-prod pipelines 7000 (v1.13.0) and 7800 (v1.13.1) to fail at the RELEASE_VERSION config-var PATCH — prod frozen at release #184 (558c9eda) since 2026-07-21. Retired by #4544: deploy-prod.yaml and deploy-queue.yaml now load HEROKU_API_KEY from vault at run time, same as every other consumer in the table above; no static WP secret remains for this credential family. Operator follow-up: DELETE /api/orgs/2/secrets/HEROKU_PROD_API_KEY in Woodpecker — unused after this PR, harmless until deleted. |
Authorization inventory (at 2026-07-01 rotation)
After the 2026-07-01 rotation, the following Heroku authorizations remain. Entries not maintained by this runbook are flagged for the operator.
| Authorization ID (prefix) | Description | Scope | Status |
|---|---|---|---|
85f3a15a |
raxx-ci-rotation-2026-07 |
global | Current CI/vault key — this runbook |
1f970877 |
prod-deploy-key |
global | Session bootstrap key (operator). Separate from vault key; see drift note below. |
f44d9427 |
github-actions-craps-deploy |
global | Legacy CI key — candidate for revocation |
73f5fe49 |
raxx-platform-token-2026-05-06 |
global | Stale — candidate for revocation |
9d5ec3c4 |
raxx-platform-token-2026-05-06 |
global | Stale duplicate — candidate for revocation |
99f3c0a4 |
billing-collector-readonly-2026-05-06 |
global | Billing stale — candidate for revocation |
059730bb |
claude-code-2026 |
global | Claude Code IDE session — not managed here |
565e7b32 |
raxx-billing-collector |
global | Billing — rotated separately |
0ef029d2, 6952371b, dce15776 |
billing collector — Raxx Console - 2026-06-04 |
read | Billing read-only — rotated separately |
Recommended operator cleanup (next maintenance window): Revoke 73f5fe49,
9d5ec3c4, f44d9427, and 99f3c0a4. These are not in active use.
Vault/session drift note (2026-07-01 discovery)
During the 2026-07-01 rotation, the vault key and the session-bootstrapped key were discovered to be different authorizations:
- Vault (source of truth):
raxx-automation: dispatch + git push(b1e6320e) — rotated out - Session (
HEROKU_API_KEY_PROD):prod-deploy-key(1f970877) — still active
Both were valid at time of discovery. The vault key was rotated (new vault key is
85f3a15a). The prod-deploy-key (1f970877) was NOT revoked — it is still
active. The operator's session HEROKU_API_KEY_PROD now points to a key that
exists but is NOT the vault-canonical key.
Action required: After verifying session-bootstrap.sh reads from vault on
refresh, revoke 1f970877 manually:
heroku authorizations:revoke 1f970877-9b2f-4899-ac67-030049fd7833
Only do this AFTER confirming the next session bootstrap reads the new vault key.
Drift detection
Between rotations, the following three stores must hold the same token
(updated 2026-08-29, #4544 — matches heroku-api-key-drift-recovery.md):
- Infisical vault:
/MooseQuest/heroku/HEROKU_API_KEY(env: prod) — source of truth - The 4 Heroku app config vars:
HEROKU_API_KEYonraxx-api-staging,raxx-api-prod,raxx-console-staging,raxx-console-prod - Session bootstrap:
HEROKU_API_KEYexported bysession-bootstrap.shfor agent sessions
There is no GitHub Actions secret in this picture — GHA deploys were retired 2026-07-10 (ADR-0135/ADR-0136) — and CI/CD (Woodpecker) has no static copy; it re-reads vault live on every pipeline run.
Drift symptom: CI deploy fails with Error: The token provided to HEROKU_API_KEY
is invalid. See heroku-api-key-drift-recovery.md for recovery procedure.
Proactive check (monthly):
# Confirm vault key authenticates to Heroku (no value printed)
VAULT_KEY=$(infisical secrets get HEROKU_API_KEY \
--env=prod --path=/MooseQuest/heroku --plain)
STATUS=$(curl -sS -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.heroku+json; version=3" \
-H "Authorization: Bearer $VAULT_KEY" \
https://api.heroku.com/account)
echo "Vault key HTTP status: $STATUS" # expect 200
Escalation
Wake the operator when:
- A rotation attempt fails at the vault-write step (new token created but not stored — two valid tokens in flight; revoke the new one and restart).
- The old authorization cannot be identified by suffix matching (possible if the key was set via a mechanism that does not create a named authorization).
- Any rotation step returns an unexpected 5xx from Heroku.
- More than one rotation attempt leaves orphan authorizations and deduplication is unclear.
Contact: ops@raxx.app or Slack D0AJ7K184TV.
References
- Full per-step SOP:
docs/ops/runbooks/rotation/heroku-platform-token.md - Drift recovery:
docs/ops/runbooks/heroku-api-key-drift-recovery.md - Session bootstrap:
docs/ops/runbooks/session-bootstrap.md - Heroku OAuth docs:
https://devcenter.heroku.com/articles/oauth - Heroku Platform API:
https://devcenter.heroku.com/articles/platform-api-reference - Issue: #251