Rotation SOP — Heroku Platform API Token
Mode: operator-assisted Last validated: 2026-04-24 UTC Validation method: read-only-docs (dry-run-on-prod-with-rollback feasible — see below) Average duration: 8m Required role: superadmin
Applies to: HEROKU_API_KEY, HEROKU_PLATFORM_API_TOKEN, and any other long-lived Heroku Platform API authorization tokens used by the console, the rotation pipeline, and CI workflows.
Earlier verbal summary said "manual." Confirmed: there is no programmatic self-rotation API. Heroku exposes
heroku authorizations:create(CLI) andPOST/DELETE /oauth/authorizations(REST) — but creating a new authorization requires a user-level OAuth token or the operator's authenticated CLI session, which itself cannot be rotated programmatically without operator presence. We classify this as operator-assisted (operator runs CLI commands) rather than fully manual (dashboard-only).
When to run
- Scheduled rotation (cadence: every 90 days)
- Operator-initiated (suspected compromise, off-cycle)
- After incident (employee offboarding, accidental commit/log of token value)
Prerequisites
- [ ] Operator authenticated to the Heroku CLI (
heroku login) on a trusted workstation - [ ] Operator has 2FA enabled on Heroku account
- [ ] Existing token version in Infisical with history
- [ ] Downstream consumer list reviewed (Raptor backend, console, GitHub Actions deploy workflows, rotation pipeline)
- [ ] Maintenance window not strictly required — Heroku permits >1 active authorization simultaneously, so old + new can coexist during the swap
Steps
1. Pre-rotation checks
# List existing authorizations
heroku authorizations
# Look for the description matching the token being rotated (e.g., "raxx-api-prod-platform-token")
# Capture the OLD authorization ID for revocation in step 7
2. Generate the new credential
# Create a new long-lived authorization
heroku authorizations:create \
--description "raxx-platform-token-2026-04-24" \
--scope "global"
Output includes the new token value (Token) and the authorization ID. Capture the token value now — it is shown once.
For a more restricted scope (preferred if the consumer doesn't need global access):
heroku authorizations:create \
--description "raxx-platform-token-2026-04-24-readonly" \
--scope "read-protected,write-protected"
REST equivalent (if CLI unavailable):
curl -sS -n -X POST \
-H "Accept: application/vnd.heroku+json; version=3" \
-H "Content-Type: application/json" \
https://api.heroku.com/oauth/authorizations \
--data '{"description":"raxx-platform-token-2026-04-24","scope":["global"]}'
3. Validate the new credential
NEW_TOKEN="<captured value>"
curl -sS -H "Accept: application/vnd.heroku+json; version=3" \
-H "Authorization: Bearer $NEW_TOKEN" \
https://api.heroku.com/account | jq '.email'
# Expect: kris@moosequest.net (or the Heroku account email)
Confirm the token can perform the actions consumers need:
# Read app config (used by rotation pipeline + console)
curl -sS -H "Accept: application/vnd.heroku+json; version=3" \
-H "Authorization: Bearer $NEW_TOKEN" \
https://api.heroku.com/apps/raxx-api-prod/config-vars | jq 'keys | length'
# Expect: a non-zero count.
4. Store in Infisical
infisical secrets set HEROKU_API_KEY="$NEW_TOKEN" \
--projectId="$INFISICAL_PROJECT_ID" --env=prod
5. Propagate to downstream consumers
| Consumer | How |
|---|---|
| Raptor (raxx-api-prod, raxx-api-staging) | heroku config:set HEROKU_API_KEY="$NEW_TOKEN" -a raxx-api-prod (and -staging) |
| Console (raxx-console-prod) | heroku config:set HEROKU_API_KEY="$NEW_TOKEN" -a raxx-console-prod |
| GitHub Actions (deploy workflows) | gh secret set HEROKU_API_KEY -a actions -b "$NEW_TOKEN" |
| Rotation pipeline | per #253 deploy spec — likely a Heroku config var on the pipeline app |
| Operator local zshrc | DM via Slack D0AJ7K184TV after Infisical sync |
6. Verify downstream
After Heroku propagation completes (each config:set triggers a dyno restart):
# Confirm Raptor is up and using the new token (health endpoint hits Heroku Platform API on a /health/dependencies route if present)
curl -sS https://api.raxx.app/health | jq '.heroku_api'
# OR a deploy-via-token smoke test on staging
gh workflow run deploy-staging.yml -f sanity=true
gh run watch
# Expect: success
7. Revoke the old credential
heroku authorizations:revoke <OLD_AUTHORIZATION_ID>
REST equivalent:
curl -sS -X DELETE \
-H "Accept: application/vnd.heroku+json; version=3" \
-H "Authorization: Bearer $ROTATING_TOKEN" \
https://api.heroku.com/oauth/authorizations/$OLD_AUTHORIZATION_ID
Verify:
curl -sS -H "Accept: application/vnd.heroku+json; version=3" \
-H "Authorization: Bearer $OLD_TOKEN" \
https://api.heroku.com/account
# Expect: HTTP 401
8. Audit log entry
action: secret.rotate.completed
actor: <admin_id>
context: {"secret_name": "HEROKU_API_KEY", "old_authorization_id": "<id>", "new_authorization_id": "<id>", "method": "operator-assisted"}
Rollback
Heroku permits multiple active authorizations. Until step 7 completes, the old authorization is still valid:
- Revert the Heroku config vars to the old token (Infisical retains the previous version).
- Restart dynos (
heroku ps:restart -a raxx-api-prod) to pick up the reverted token. - Skip step 7 for now; investigate why the new token is broken; then redo from step 2 (creating a fresh new authorization, not reusing the broken one).
After step 7 (revocation), the old token cannot be restored — Heroku does not retain revoked authorizations.
Vendor doc references
- OAuth authorizations: https://devcenter.heroku.com/articles/oauth
- CLI plugin: https://github.com/heroku/heroku-oauth (
heroku authorizations) - Dashboard: https://dashboard.heroku.com/account/applications
- Platform API reference: https://devcenter.heroku.com/articles/platform-api-reference
Known gotchas
- CLI session ≠ programmatic.
heroku authorizations:createrequires an authenticated CLI session, which is itself a token. We cannot rotate Heroku tokens 100% unattended; an operator must run the create command (or a CI job using a separate, manually-rotated, longer-lived management token). config:settriggers dyno restart. Plan for ~30s of dyno cycle per app. Useheroku config:setwith multiple keys at once to batch restarts.- Authorization IDs are not the same as token values. The token (
HEROKU_API_KEY) is the secret string; the authorization ID is the management handle for revocation. Capture both at creation. heroku authorizations:rotatedoes not exist. Despite intuition, the Heroku CLI does not have a one-shot rotate. Use create-then-revoke explicitly.- 2FA-required accounts issue tokens that work without 2FA prompts (this is the point of an authorization vs an interactive session). Do not be alarmed that the new token works without a 2FA prompt — that is expected.
- Web-app-flow OAuth tokens expire after 8 hours; direct-authorization-flow tokens (the ones we use) do not auto-expire. They only end via revoke.