Raxx · internal docs

internal · gated ↑ index

Rotation SOP — GitHub App Installation Access Token (informational)

Mode: programmatic / auto-rotated Last validated: 2026-04-24 UTC Validation method: read-only-docs Average duration: N/A (fully automated) Required role: ops (only for inspection — no operator action required for routine rotation)

Applies to: GitHub App installation access tokens. These are short-lived bearer tokens minted from a GitHub App's private key (the private key is what is stored long-term). If we use a GitHub App for any Raxx automation (e.g., a future "Raxx Bot" GitHub App), the installation token is auto-rotated; the private key is what we actually rotate manually.

This SOP is mostly informational. Installation access tokens have a lifetime of 1 hour and are auto-regenerated by SDKs (Octokit, etc.) before expiry. Routine "rotation" is a non-event for the operator. The thing that actually requires manual rotation is the GitHub App private key, which is covered separately.

When to run

This SOP is only invoked in three scenarios: 1. Diagnosing why an installation token isn't being minted (treat as an incident, not a rotation). 2. Forcing all currently-issued installation tokens to expire ahead of schedule (e.g., post-compromise — by rotating the App's private key, all subsequently-minted installation tokens are signed with the new key; tokens already issued continue to work for up to 1 hour). 3. Onboarding a new GitHub App installation (the first installation token is minted as part of install flow; no rotation involved).

Prerequisites

How installation token minting works (reference)

# Pseudocode — Octokit and similar SDKs do this for you
import jwt, time, requests

def mint_installation_token(app_id, private_key, installation_id):
    now = int(time.time())
    payload = {"iat": now, "exp": now + 600, "iss": app_id}
    bearer = jwt.encode(payload, private_key, algorithm="RS256")
    resp = requests.post(
        f"https://api.github.com/app/installations/{installation_id}/access_tokens",
        headers={
            "Authorization": f"Bearer {bearer}",
            "Accept": "application/vnd.github+json"
        }
    )
    return resp.json()  # {"token": "ghs_...", "expires_at": "..."}

The returned token is valid for 1 hour. SDKs auto-mint a new one as needed.

"Rotation" steps (forced re-issuance scenario)

1. Pre-rotation check

2. Generate a new private key

  1. Settings → Apps → your App → Generate a private key.
  2. Download the new PEM file.
  3. There is no "key revocation" UI for App private keys. Instead, after generating a new key, delete the old key from the App's Private keys section.

3. Validate the new private key

# Mint a token using the new key
python -c "
import jwt, time, sys
key = open('/path/to/new-private-key.pem').read()
now = int(time.time())
payload = {'iat': now, 'exp': now + 600, 'iss': $APP_ID}
print(jwt.encode(payload, key, algorithm='RS256'))
" | xargs -I {} curl -sS -X POST \
  -H "Authorization: Bearer {}" \
  -H "Accept: application/vnd.github+json" \
  "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens" | jq '.token'
# Expect: a `ghs_...` token string

4. Store in Infisical

infisical secrets set GITHUB_APP_PRIVATE_KEY="$(cat /path/to/new-private-key.pem)" \
  --projectId="$INFISICAL_PROJECT_ID" --env=prod

5. Propagate

Consumer How
Apps that mint installation tokens heroku config:set GITHUB_APP_PRIVATE_KEY="$(cat new-key.pem)" -a <app>

6. Verify

After consumer restart, watch logs for successful installation token mints:

heroku logs --tail -a <app> | grep -iE 'github.*installation|access_tokens'
# Expect: 200 responses from /app/installations/.../access_tokens

7. Revoke the old private key

  1. App settings → Private keys → old key → Delete.
  2. Confirm.

After deletion, no new installation tokens can be minted with the old key, but installation tokens already minted with the old key remain valid for up to 1 hour. There is no path to invalidate already-minted installation tokens directly — they age out naturally. For a compromise scenario where 1 hour is unacceptable, also rotate the consumers of the old installation tokens (e.g., revoke the GitHub App's installation entirely and reinstall) — that is a heavier path.

8. Audit log entry

action: secret.rotate.completed
actor: <admin_id>
context: {
  "secret_name": "GITHUB_APP_PRIVATE_KEY",
  "app_id": "<id>",
  "installation_id": "<id>",
  "method": "operator-assisted-app-settings"
}

Rollback

GitHub Apps allow multiple private keys simultaneously. Until step 7, both old and new private keys can mint installation tokens. To roll back: 1. Revert consumers to the OLD private key from Infisical history. 2. Restart consumers. 3. Skip step 7.

After step 7 (delete old key), the old key is unrecoverable. Generate a fresh new private key.

Vendor doc references

Known gotchas