Rotation SOP — GitHub Personal Access Token (PAT)
Mode: operator-assisted (scripted propagation available — see Step 5) Last validated: 2026-05-05 UTC Validation method: read-only-docs Average duration: 5m (scripted) / 7m (manual) Required role: ops
Applies to: GITHUB_API_DISPATCH_TOKEN, GITHUB_API_READONLY_TOKEN, and any other classic or fine-grained PATs used by Raxx infrastructure for GitHub API access (CI workflows, console build status integration, repo automation).
Bridge script available. Steps 5–6 can be run in one command once the new token is stored in vault. See Step 5 for usage. This script is an interim bridge until Velvet's GitHub adapter (#947) absorbs the responsibility natively.
Recommendation: migrate to fine-grained PATs. GitHub recommends fine-grained tokens over classic PATs whenever possible. Fine-grained tokens have better scoping, mandatory expiry, per-repo permissions, and clearer audit trails. New tokens should be fine-grained unless a needed feature is classic-only. Track legacy classic PATs in the routing matrix and migrate on the next rotation cycle.
When to run
- Scheduled rotation (cadence: every 90 days — fine-grained PATs require explicit expiry, so cadence aligns with the chosen expiry window)
- Operator-initiated (suspected compromise, off-cycle)
- After incident (employee offboarding, accidental commit/log of token, leaked-token GitHub revocation notice)
Prerequisites
- [ ] GitHub account with appropriate access via SSO + 2FA
- [ ] Existing token in Infisical with history
- [ ] Downstream consumer list (GitHub Actions secrets, Heroku config vars, console build-status reader)
- [ ] Decision on classic vs fine-grained for this rotation (default: fine-grained)
- [ ] If classic PAT being replaced with fine-grained: list of required scopes mapped to the fine-grained permission model
Steps
1. Pre-rotation checks
# Confirm current token works
curl -sS -H "Authorization: Bearer $CURRENT_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/user | jq '.login'
# Expect: the GitHub username (or app/bot login)
For fine-grained tokens, also verify scope:
# List the resources the token can see
curl -sS -H "Authorization: Bearer $CURRENT_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/user/repos | jq '. | length'
2. Generate the new credential
Fine-grained PAT (preferred):
1. Navigate to https://github.com/settings/personal-access-tokens/new.
2. Token name: descriptive (e.g., raxx-console-readonly-2026-04-24).
3. Expiration: select 90 days (or shortest practical for the use case).
4. Resource owner: select MooseQuest org (or personal account).
5. Repository access: select specific repos this token needs (least-privilege).
6. Permissions: select the minimal set:
- For GITHUB_API_READONLY_TOKEN: Repository → Actions: Read-only, Repository → Metadata: Read-only, Repository → Contents: Read-only.
7. Click Generate token.
8. Copy the token value immediately — shown once. Format: github_pat_....
Classic PAT (only if fine-grained cannot satisfy the use case):
1. Navigate to https://github.com/settings/tokens/new.
2. Note: descriptive (e.g., raxx-classic-2026-04-24).
3. Expiration: 90 days.
4. Scopes: minimal set (e.g., read:org, repo:status, actions:read).
5. Click Generate token.
6. Copy. Format: ghp_....
3. Validate the new credential
NEW_TOKEN="..."
curl -sS -H "Authorization: Bearer $NEW_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/user | jq '.login'
# Expect: same username/login as step 1
Validate against the consumer's actual workload:
# Example: console reads recent CI runs
curl -sS -H "Authorization: Bearer $NEW_TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/raxx-app/TradeMasterAPI/actions/runs?per_page=1" | jq '.workflow_runs[0].status'
# Expect: a run status string ("completed", etc.)
4. Store in Infisical
infisical secrets set GITHUB_API_READONLY_TOKEN="$NEW_TOKEN" \
--projectId="$INFISICAL_PROJECT_ID" --env=prod
5. Propagate to downstream consumers
Option A — scripted (preferred)
Run the bridge propagation script. It reads the new value from vault, sets it on all Heroku apps and the GitHub Actions secret, then verifies via the GitHub API:
# Required env vars (set before running):
export INFISICAL_CLIENT_ID="..."
export INFISICAL_CLIENT_SECRET="..."
export INFISICAL_PROJECT_ID="..."
export HEROKU_API_KEY="..."
export GITHUB_TOKEN="..." # a token with secrets:write on raxx-app/TradeMasterAPI
# Propagate GITHUB_API_DISPATCH_TOKEN (defaults: both console apps + repo secret):
scripts/ops/rotate-github-pat.sh GITHUB_API_DISPATCH_TOKEN
# Propagate GITHUB_API_READONLY_TOKEN:
scripts/ops/rotate-github-pat.sh GITHUB_API_READONLY_TOKEN
# Dry-run to preview what would happen without touching anything:
scripts/ops/rotate-github-pat.sh GITHUB_API_DISPATCH_TOKEN --dry-run
Override defaults if needed:
scripts/ops/rotate-github-pat.sh GITHUB_API_DISPATCH_TOKEN \
--vault-path /MooseQuest/github/ \
--apps raxx-console-prod,raxx-console-staging \
--gh-secret-repo raxx-app/TradeMasterAPI
The script prints a SUMMARY with the status of each destination and next-steps for revoking the old token.
Note: This script is a bridge artifact until Velvet's Heroku + GitHub adapters (#947) ship. When #947 merges, this manual step is replaced by the Velvet rotation flow.
Option B — manual (use if the script is broken)
| Consumer | How |
|---|---|
| Console (raxx-console-prod) | heroku config:set GITHUB_API_DISPATCH_TOKEN="$NEW_TOKEN" -a raxx-console-prod >/dev/null 2>&1 |
| Console (raxx-console-staging) | heroku config:set GITHUB_API_DISPATCH_TOKEN="$NEW_TOKEN" -a raxx-console-staging >/dev/null 2>&1 |
| Raptor (if it makes GitHub API calls) | heroku config:set GITHUB_API_DISPATCH_TOKEN=... -a raxx-api-prod >/dev/null 2>&1 |
GitHub Actions (a workflow that calls gh api outside default GITHUB_TOKEN) |
gh secret set GITHUB_API_DISPATCH_TOKEN -b "$NEW_TOKEN" -R raxx-app/TradeMasterAPI |
| Operator local zshrc | DM via Slack D0AJ7K184TV |
Safety: always append
>/dev/null 2>&1to everyheroku config:setcall. The Heroku CLI echoes the entire config-var payload to stdout on success, exposing the secret value in terminal history and CI logs. Seefeedback_heroku_config_set_echoes_secrets.md.
6. Verify downstream
After dyno restart:
# Console build-status panel should populate
curl -sS https://console.raxx.app/api/status/builds \
-H "Cookie: <ops session>" | jq '.builds.api[0].conclusion'
# Expect: a string (not null), no rate-limit errors
# Tail logs for any GitHub API auth errors
heroku logs --tail -a raxx-console-prod | grep -iE 'github|401|403'
# Expect: no auth failures.
7. Revoke the old credential
GitHub does not support "regenerate in place" — you must revoke the old token explicitly:
- Navigate to
https://github.com/settings/tokens(classic) orhttps://github.com/settings/personal-access-tokens(fine-grained). - Locate the OLD token.
- Click Delete (classic) or Revoke (fine-grained).
- Confirm.
Verify:
curl -sS -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $OLD_TOKEN" \
https://api.github.com/user
# Expect: 401
8. Audit log entry
action: secret.rotate.completed
actor: <admin_id>
context: {
"secret_name": "GITHUB_API_READONLY_TOKEN",
"token_type": "fine-grained" or "classic",
"method": "operator-assisted-settings"
}
Rollback
Until step 7, both tokens are valid. To roll back: 1. Revert Heroku config vars to the OLD token from Infisical history. 2. Restart consumers. 3. Skip step 7. Investigate.
After step 7 (revoke), the old token is dead. Generate a brand-new fresh token and redo from step 2.
Vendor doc references
- Managing PATs: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
- Fine-grained PATs (settings): https://github.com/settings/personal-access-tokens
- Classic PATs (settings): https://github.com/settings/tokens
- Token leak revocation API:
POST /credentials/revoke(for tokens belonging to others)
Known gotchas
- Fine-grained vs classic feature gap. Some operations (e.g., certain org-level admin endpoints) currently only support classic PATs. Verify the consumer's actual API needs before migrating. Track classic PATs in the routing matrix with a "migrate to fine-grained" flag.
- Fine-grained PATs require explicit expiry. Maximum is 1 year; recommend 90 days. Calendar a recurring rotation accordingly.
- No regenerate-in-place. Always create new + revoke old. Different from CF user tokens.
- GitHub Actions has its own
GITHUB_TOKENthat is auto-rotated per workflow run. Don't conflate that with PATs —GITHUB_TOKENis not in scope for this SOP. - Org SSO requirement. If the org enforces SSO, the PAT must be authorized against the org's SSO provider after creation; an unauthorized PAT silently 404s on org-private resources.
- Token format prefix is the diagnostic for accidental leaks: classic PATs start
ghp_, fine-grained startgithub_pat_. GitHub's secret scanner uses these prefixes.