This guide is specifically about GitHub's machinery: what a secret scanning alert means, how push protection works and what bypassing it implies, what GitHub retains after a history rewrite, and how to lock an organisation down afterwards.

For the general incident sequence — revoke, classify, purge, hunt copies — start with I accidentally committed an API key. The two are meant to be read together.

Before anything else: revoke the credential at the provider. Not after you've cleaned history — now. GitHub has the blob, every clone and fork has it, and automated scrapers watching public commit feeds act in seconds. Rewriting history changes what future clones see; it does nothing about copies already made.


1. Understand which alert you got

GitHub has two distinct mechanisms, and they mean different things.

Secret scanning runs after a push and matches known provider patterns. An alert here means the secret reached GitHub's servers. Treat it as exposed. (About secret scanning)

Push protection runs during the push and blocks it. If it worked, the secret never landed — you rewrite your local commit and move on, no incident. (About push protection)

If you bypassed push protection — chose "it's a test secret" or "I'll fix it later" to get the push through — treat that as high severity. The bypass is logged, and the value is now on GitHub. GitHub asks for a reason precisely because the bypass is the moment the incident starts.

Some providers are partners in GitHub's programme and get notified directly when their key pattern appears in a public repo. That's genuinely useful — many will auto-revoke — but don't rely on it. Revoke yourself.

List open alerts across an org:

gh api -X GET /orgs/YOUR_ORG/secret-scanning/alerts \
  -f state=open --paginate \
  --jq '.[] | "\(.secret_type)\t\(.repository.full_name)\t\(.html_url)"'

2. Assess blast radius before you touch history

Answer these first; they determine how much of the rest applies.

Was the repo ever public? Assume full compromise. Not "possibly" — assume it, and act accordingly.

Are there forks? This is the one people miss, and it's decisive:

gh api /repos/OWNER/REPO --jq '.forks_count'
gh api /repos/OWNER/REPO/forks --jq '.[].full_name'

You cannot rewrite history in a fork you don't own. If the repo is public and forked, the secret remains reachable in those forks permanently. Revocation is your only real remedy — history rewriting is housekeeping at that point.

Did CI run on the commit? Workflow logs may contain the value, and logs are often readable by more people and retained longer than you'd guess:

gh run list --repo OWNER/REPO --limit 50
gh run view RUN_ID --log | rg 'sk_live_|AKIA'

You can delete logs, but the secret is already revoked by now, which is what actually matters:

gh api -X DELETE /repos/OWNER/REPO/actions/runs/RUN_ID/logs

3. Remove it from the working tree

git rm --cached .env

cat >> .gitignore <<'EOF'
.env
.env.*
!.env.example
EOF

git add .gitignore
git commit -m "Remove tracked environment file; credentials rotated"

Don't name the specific credential in a public repo's commit message — you're advertising what to look for in history.

Note this only cleans HEAD. The blob is still fully present in history, and git revert doesn't help: it adds a commit that undoes the change while leaving every old object intact and readable.


4. Purge from history

Verify what's actually there before rewriting anything:

# Every commit that touched the file, including deletions and renames
git log --all --full-history --oneline -- .env

# Is the value reachable anywhere in the object database?
git grep -I 'sk_live_' $(git rev-list --all) 2>/dev/null | head

GitHub's supported tool is git-filter-repo. The deprecated filter-branch is slow enough to be a trap on any real repository. (Removing sensitive data)

brew install git-filter-repo

# Work on a fresh clone — filter-repo rewrites aggressively and is not undoable
git clone https://github.com/OWNER/REPO.git repo-clean
cd repo-clean

# Remove a file from all history…
git filter-repo --invert-paths --path .env

# …or keep the file and scrub only the value
cat > /tmp/replacements.txt <<'EOF'
sk_live_51H8xQwErTyUiOpAsDfGh==>REDACTED
regex:AKIA[0-9A-Z]{16}==>REDACTED
EOF
git filter-repo --replace-text /tmp/replacements.txt
rm /tmp/replacements.txt      # it contains the secret in plaintext

git filter-repo removes the origin remote deliberately, so you can't force-push a botched rewrite by muscle memory. Verify, then re-add and push:

# Should return nothing
git grep -I 'sk_live_' $(git rev-list --all) 2>/dev/null

git remote add origin https://github.com/OWNER/REPO.git
git push --force --all
git push --force --tags

What GitHub retains afterwards

The part that surprises people, and the clearest argument for revoking first:

Orphaned commits stay reachable by SHA for a while. After a rewrite, the old objects become unreferenced but not immediately gone. Anyone with the commit SHA — and it's in the alert email, in PR timelines, in CI logs — may still be able to fetch it until garbage collection runs. To purge cached views you must contact GitHub Support and ask explicitly.

Pull requests keep their own references. A PR that contained the commit may still display the diff.

Forks are untouched, as covered above.

So even a textbook-perfect rewrite leaves a window. A revoked key has no window.

Coordinating the force-push

Force-pushing rewritten history is disruptive, and the disruption is worth planning for:

  • Every SHA changes. Existing clones are incompatible. Tell collaborators to re-clone, or hard-reset:
    git fetch origin && git reset --hard origin/main
    
  • Anyone who merges old history back in reintroduces the blobs. This is the most common way a cleanup gets undone — someone rebases a stale branch and the secret returns.
  • Open PRs break and generally need reopening.
  • Temporarily protect the branch against further pushes while you work, then restore the rules.
  • Announce it before you push, not after.

5. Lock the organisation down

Once the immediate incident is handled, the goal is that the next one gets blocked instead of detected.

Enable push protection org-wide. It stops known patterns at push time, which is strictly better than an alert afterwards. Secret scanning and push protection are free for public repositories.

Add scanning to pull requests, so it isn't only GitHub's pattern list:

name: secret-scan
on: [pull_request]
jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # gitleaks needs history to scan the diff range
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

fetch-depth: 0 matters — with the default shallow clone, there's no history to scan and the job passes while finding nothing.

Give developers a local hook, so the block happens before a commit exists:

brew install gitleaks
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
gitleaks protect --staged --redact --verbose || {
  echo "gitleaks: staged changes contain a probable secret. Commit aborted."
  exit 1
}
EOF
chmod +x .git/hooks/pre-commit

Share hooks across a team with core.hooksPath and a committed hooks directory — see keep secrets out of Git.

Review bypass events periodically. A pattern of push-protection bypasses is a process problem, not individual carelessness — usually it means people don't have a fast, sanctioned place to put a secret instead.

Migrate to short-lived credentials where you can. OIDC for GitHub Actions removes stored cloud keys entirely; GitHub App installation tokens expire by construction. A credential that can't be committed because it doesn't exist as a durable string is the real fix.


6. Clean up developer machines

The revoked value is harmless now, but the habit of stale local copies is what makes the next leak likely.

# Stray env files anywhere under your code directory
fd -H -t f '^\.env' ~/Developer

# Did it ever land in shell history?
rg -n 'sk_live_|AKIA|ghp_' ~/.zsh_history ~/.bash_history 2>/dev/null

Delete .env.backup, .env.old, and the rest. Update the vault entry rather than adding a second one — and if you keep the retired value for reference, label it clearly with its retirement date so nobody pastes it into a terminal at 2am.

The deeper fix is that a plaintext secret sitting one git add . away from a repository will eventually be committed by someone in a hurry. That's a predictable outcome, not a discipline failure. Keeping canonical values in a local vault like PassStore and generating env files only when tooling needs them means there's no long-lived file in the tree to catch.


Do I have to rewrite history at all?

Often, no — and this is a genuinely defensible position.

Skip the rewrite when: the credential is revoked, the repo is private with a trusted collaborator set, there are no forks, and the value has no meaning now that it's dead. A revoked key in history is a string, not a risk. The rewrite costs real coordination, breaks every clone, and risks someone reintroducing the blob.

Do the rewrite when: the repo is public or will become public; the secret can't be fully revoked (a hardcoded signing key, a customer-supplied credential, something with a long deprecation window); the content isn't a credential at all but data that must not be there — personal data, proprietary code, a large binary; or compliance requires demonstrable removal.

The failure mode worth avoiding is spending an afternoon on git filter-repo while a live key sits in someone else's clone. Revoke first; then decide calmly whether the rewrite is worth it.