Rotating a compromised commit-signing key Jump to heading

A leaked signing key is a supply-chain emergency: anyone holding it can push commits that your platform, your CI gates, and your teammates all render with a green “Verified” badge. The instinct to yank the key everywhere at once is correct for containment but catastrophic for history — a naive removal invalidates every legitimate signature the key ever made, turning years of trusted commits red overnight. This page is the incident recipe for doing it correctly: revoke fast, but preserve the verifiability of everything signed before the compromise. It builds directly on Protecting & Rotating Signing Keys, which covers key custody, storage, and lifecycle.

When to use this approach Jump to heading

Run this recipe when any of the following is true:

  • A private signing key (GPG secret key, or the SSH private key used for gpg.format ssh) was committed to a repository, pasted into a chat or ticket, or left in a CI log.
  • A laptop, YubiKey, or backup containing the unprotected key was lost or stolen.
  • A developer with signing authority left under circumstances that require assuming their key is no longer under sole control.
  • A “Verified” commit appears that the named author swears they did not make.

The defining constraint of this scenario, and the reason it is not a routine rotation, is the split timeline. There is a moment of compromise. Everything the key signed before that moment is legitimate and must stay verified. Everything after it is suspect and must be re-examined. Your job is to draw that line precisely and encode it in your trust roots, not to erase the key wholesale. Once contained, coordinate the tightened verdicts with your Commit Verification Gates so a rejected signature actually blocks a merge.

SAFETY WARNING: Removing or expiring a signing key without a valid-before window invalidates every signature that key ever made, including legitimate pre-incident commits — the entire history under that key turns “unverified”. Recovery is to restore a date-scoped allowed_signers entry (SSH) or re-import the key with its original expiry and rely on the revocation date (GPG) so signatures dated before the compromise verify again. Always set the boundary before you delete anything.

The diagram below shows the split timeline and how a date-bounded trust root keeps two verdicts alive at once.

Compromise timeline split by a valid-before boundaryA horizontal timeline of commits. Commits to the left of the compromise boundary are signed by the old key and remain verified. The boundary is the valid-before timestamp. Commits to the right are inside the compromise window and must be audited; new commits after redistribution are signed by the replacement key.timeold key — verifiedvalid-beforecompromise instantcompromise window — auditnew key

Step-by-step recipe Jump to heading

Step 1 — Contain the incident and fix the compromise timestamp Jump to heading

Before touching any trust root, pin the boundary. Choose the earliest time the key could plausibly have been exposed — the push that leaked it, the theft report time, the start of the CI log’s retention window. Erring earlier costs you a slightly larger audit window; erring later means trusting signatures that may be forged.

# Record the boundary as a UTC timestamp — you will reuse it in every step.
# Format for SSH allowed_signers is YYYYMMDDHHMMSSZ (UTC, Z suffix).
COMPROMISE="20260705T142200Z"          # human-readable note for the incident log
COMPROMISE_ALLOWEDSIGNERS="20260705142200Z"
echo "Compromise boundary: $COMPROMISE" | tee -a incident-signing-key.log

Verify the timestamp is genuinely earlier than any commit you consider trustworthy:

# The most recent commit you KNOW is legitimate must predate the boundary.
git log -1 --format='%cI %H' <last-trusted-commit>

Step 2 — Revoke the key at the hosting platform Jump to heading

Platform revocation is the fastest containment lever: it stops the leaked key from minting new “Verified” badges on the remote immediately, independent of what every clone believes locally. Remove the public key from the developer’s account signing keys.

# GitHub CLI — list signing keys, then delete the compromised one by ID.
gh api /user/gpg_keys        --jq '.[] | "\(.id)\t\(.key_id)"'   # GPG
gh api /user/ssh_signing_keys --jq '.[] | "\(.id)\t\(.title)"'   # SSH signing

gh api --method DELETE /user/gpg_keys/<key-db-id>
gh api --method DELETE /user/ssh_signing_keys/<key-db-id>

Verify the key is gone from the account:

# Should no longer list the compromised key_id / title.
gh api /user/gpg_keys --jq '.[].key_id'
gh api /user/ssh_signing_keys --jq '.[].title'

Platform-side revocation alone is not enough: local verifiers and CI runners each carry their own trust roots. Steps 3–5 propagate the boundary to those.

Step 3 — Date-scope the key in allowed_signers or publish a GPG revocation certificate Jump to heading

This is the load-bearing step. The path depends on your signing format.

SSH signing (gpg.format ssh). The allowed_signers file accepts valid-before and valid-after options per principal. Keep the compromised key’s line but bound it, and add a fresh line (Step 4) for the replacement:

# allowed_signers — one principal per line.
# OLD key: trusted only for signatures made BEFORE the compromise instant.
[email protected] valid-before=20260705142200Z ssh-ed25519 AAAAC3Nz...OLDKEY

# NEW key (added in Step 4): trusted only from the boundary onward.
[email protected] valid-after=20260705142200Z  ssh-ed25519 AAAAC3Nz...NEWKEY

Point Git at the file and confirm the split verdict holds:

git config gpg.ssh.allowedSignersFile "$(git rev-parse --show-toplevel)/.allowed_signers"

# A pre-boundary commit must stay "Good signature".
git log --show-signature -1 <pre-boundary-commit>
# A commit forged with the old key AFTER the boundary must now FAIL.
git log --show-signature -1 <suspect-commit>

GPG signing. Import (or generate) and publish the key’s revocation certificate. A revocation carries a reason and a date; GnuPG then treats signatures according to that revocation. If you never generated a revocation certificate up front, produce one now while you still have the secret key:

# Generate a revocation certificate (reason 1 = key has been compromised).
gpg --output revoke-<keyid>.asc --gen-revoke <KEYID>

# Import it locally, then distribute to teammates and keyservers.
gpg --import revoke-<keyid>.asc
gpg --keyserver hkps://keys.openpgp.org --send-keys <KEYID>

Verify the key now shows as revoked and that history verification reflects it:

gpg --list-keys <KEYID>      # header should read: pub  ... [revoked: <date>]
git verify-commit <pre-boundary-commit>   # still reports a good signature

SAFETY WARNING: gpg --gen-revoke needs the secret key. If the key was lost (not merely leaked) and you have no pre-generated revocation certificate, you cannot mint one — you can only remove the public key from platforms and keyservers and rely on allowed_signers-style date scoping in every verifier. Generate and offline-store a revocation certificate for every signing key before an incident; recovery afterward is otherwise impossible.

Step 4 — Generate and register a replacement key Jump to heading

Mint a new key on hardware-backed or otherwise protected storage and register its public half at the platform.

# SSH signing key (ed25519), passphrase-protected.
ssh-keygen -t ed25519 -C "[email protected] signing $(date +%F)" -f ~/.ssh/git-signing-new
git config user.signingkey ~/.ssh/git-signing-new.pub
git config gpg.format ssh
git config commit.gpgsign true

# Upload the public key as a SIGNING key (not an auth key).
gh api --method POST /user/ssh_signing_keys \
  -f title="git-signing $(date +%F)" -f key="$(cat ~/.ssh/git-signing-new.pub)"

Verify a fresh commit signs and verifies under the new key:

git commit --allow-empty -m "chore: verify new signing key" -S
git log --show-signature -1        # expect: Good "git" signature ... NEWKEY

Step 5 — Redistribute the new key and updated trust roots Jump to heading

A rotation only holds when every verifier agrees. Commit the updated allowed_signers (or publish the revoked+new GPG public keys) and force each consumer to refresh: developer clones, CI verification jobs, and any Commit Verification Gates that gate merges on signature validity.

git add .allowed_signers
git commit -m "security: date-scope compromised signing key, add replacement"
git push

Verify the distributed root is what you intend — both lines present, correctly bounded:

# Old key carries valid-before; new key carries valid-after; no bare old entry.
grep -E 'valid-(before|after)=' .allowed_signers

Step 6 — Audit commits signed inside the compromise window Jump to heading

Every commit dated at or after the boundary that was signed with the old key is now suspect. Enumerate them and confirm each against its author out of band.

# List commits from the boundary onward, with signer and status.
# %G? -> G good, B bad, U unknown, N none, R revoked, E cannot check
git log --since="2026-07-05T14:22:00Z" \
  --pretty='%h  %cI  %G?  %GS  %an  %s' --all

Any line marked R (revoked) or E, or a G that the named author disowns, is a forgery candidate. Verify each survivor explicitly:

# Re-verify one suspect commit against the current, date-scoped trust root.
git verify-commit <suspect-sha> 2>&1

Record confirmed-good, disowned, and unverifiable commits in the incident log. Disowned commits reachable from a protected branch are a history-integrity problem — treat their removal as a separate, reviewed operation, not part of this rotation.

Step 7 — Re-verify history and close the incident Jump to heading

Do a full sweep to prove the split verdict is intact end to end: pre-boundary history stays green, the compromise window is accounted for, and new commits verify under the replacement key.

# Count signatures by status across all history.
git log --all --pretty='%G?' | sort | uniq -c

Confirm the three invariants before closing:

git verify-commit <oldest-legit-commit>   # pre-boundary: Good
git verify-commit <post-rotation-commit>  # new key:      Good
git log --show-signature -1 <forged-sha> 2>&1 | grep -qi 'revoked\|No principal\|bad' \
  && echo "forgery correctly rejected"

Rotate any secrets the leaked key could also have unlocked, close the platform revocation ticket, and file the incident log with the compromise timestamp, the audited window, and the new key fingerprint.

Validation checklist Jump to heading

Frequently asked questions Jump to heading

Why not just delete the compromised key from allowed_signers? Jump to heading

A bare deletion removes the key for all of history, so every commit the developer ever signed — including legitimate pre-incident ones — instantly fails verification and reads as unverified. Instead, keep the entry but bound it with a valid-before timestamp set to the moment of compromise. Signatures dated before that instant still verify; anything dated at or after it is rejected. You get containment and continuity from the same one-line change.

How do I know exactly when the key was compromised? Jump to heading

You rarely know the exact instant, so pick the earliest defensible time: the moment the secret could first have been exposed — the push that leaked it, the laptop-theft timestamp, the start of the CI log’s retention window. Set valid-before to that earliest time. Erring earlier is the safe direction: it forces re-verification of a slightly larger window rather than extending trust to a signature that might be forged.

Does revoking a GPG key rewrite my commit history? Jump to heading

No. Revocation publishes a signed statement that the key should no longer be trusted; it does not touch any commit object, tree, or SHA. Commits keep their original bytes and their original signatures. What changes is the verifier’s verdict — after importing the revocation certificate, GnuPG reports the key as revoked and git log --show-signature flags signatures accordingly, while genuinely pre-revocation signatures still resolve as good.

  • Protecting & Rotating Signing Keys — the parent page covering key custody, hardware-backed storage, pre-generated revocation certificates, and routine (non-incident) rotation cadence.
  • Commit Verification Gates — how the tightened, date-scoped verdicts from this recipe get enforced so a rejected signature actually blocks a merge rather than merely printing a warning.