Protecting & Rotating Signing Keys Jump to heading
A commit signature is only as trustworthy as the private key that produced it. If that key leaks, an attacker can forge commits that pass every verification gate you have built; if you lose it, you can no longer sign — and a careless rotation can make years of legitimately signed history appear untrusted overnight. This guide, part of the Commit Signing & Git Supply-Chain Security pillar, covers the full custody lifecycle: where to store the private half, how to set an expiry, how to rotate on a schedule without breaking historical verification, how to revoke a compromised key, and how to hand a signing key to CI safely. The techniques apply to both signing backends compared in GPG vs SSH Commit Signing; where they diverge, both are shown.
Prerequisites Jump to heading
Before working through the key-custody lifecycle, confirm your environment:
Key Lifecycle Overview Jump to heading
Every signing key moves through the same four stages: it is generated, it is active for a bounded window, it is rotated with a deliberate overlap so the new key is trusted before the old one retires, and finally it expires or is revoked. The crucial insight is that the allowed_signers trust window for a key does not close when you stop signing with it — it stays open for the past so that commits made during the active window keep verifying forever.
Step 1 — Generate Keys With an Expiry Jump to heading
Intent: Create a signing key that is bounded in time from birth, so an undetected compromise cannot be exploited indefinitely.
For SSH signing, generate a dedicated key — never reuse your authentication key for signing, so the two can be rotated independently:
# Dedicated Ed25519 signing key, comment tags it by purpose and date
ssh-keygen -t ed25519 -C "signing-2026 [email protected]" -f ~/.ssh/id_signing_2026
# Tell Git to sign commits with SSH using this key
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_signing_2026.pub
git config --global commit.gpgsign true SSH keys carry no intrinsic expiry — the expiry is expressed in the allowed_signers window (Step 4). For a hardware-backed key that never exposes the private half to disk, generate an ed25519-sk key instead:
# Requires a FIDO2 token (YubiKey 5, Nitrokey). Private key stays on the token.
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-C "signing-2026-yubikey [email protected]" -f ~/.ssh/id_signing_sk_2026 For GPG signing, set the expiry at creation time so the key has a hard end-of-life:
# Non-interactive generation with a 1-year expiry
gpg --quick-generate-key "Alice <[email protected]>" ed25519 sign 1y
# Capture the key ID for Git
gpg --list-secret-keys --keyid-format=long [email protected]
git config --global gpg.format openpgp
git config --global user.signingkey <LONG_KEY_ID>
git config --global commit.gpgsign true Immediately generate a GPG revocation certificate and store it offline — you will need it in Step 5 if the key is ever lost or stolen:
# Pre-generate the revocation certificate NOW, before you need it
gpg --output ~/revoke-2026.asc --gen-revoke <LONG_KEY_ID>
# Move revoke-2026.asc to offline storage (password manager, printed, air-gapped USB) Verify: Make a signed test commit and confirm Git reports a good signature:
git commit --allow-empty -m "chore: signing smoke test" -S
git log --show-signature -1
# Expected (SSH): Good "git" signature for [email protected] with ED25519 key ...
# Expected (GPG): gpg: Good signature from "Alice <[email protected]>" SAFETY WARNING: Never add a private key file (
id_signing_2026,secring.gpg, exported--export-secret-keysoutput) to a repository, a Docker image, or a Gist. Addid_*and*.gpgpatterns to a global gitignore and scan with a pre-push secret scan. If a private key is ever committed, treat it as compromised: revoke and rotate immediately (Step 5).
Step 2 — Store the Private Key in an Agent, Token, or Keychain Jump to heading
Intent: Keep the private key out of plaintext on disk and out of process memory longer than necessary, while still allowing unattended signing during a working session.
The private key should sit in one of three places, in descending order of preference: a hardware token, an OS keychain, or an in-memory agent loaded from an encrypted-at-rest file.
ssh-agent holds the decrypted key for the session so you type the passphrase once:
# Start the agent and load the passphrase-protected key
eval "$(ssh-agent -s)"
ssh-add --apply-to-destination-constraint-none ~/.ssh/id_signing_2026 # prompts for passphrase
# Confirm the key is loaded
ssh-add -l
# Expected: 256 SHA256:... ~/.ssh/id_signing_2026 (ED25519) gpg-agent does the equivalent for GPG and caches the passphrase with a configurable TTL:
# ~/.gnupg/gpg-agent.conf — cache passphrase for 1 hour, max 8 hours
default-cache-ttl 3600
max-cache-ttl 28800
# Reload the agent to apply
gpg-connect-agent reloadagent /bye OS keychains integrate the agent with the platform credential store so the passphrase is protected by your login:
# macOS: store the SSH key passphrase in the login Keychain
ssh-add --apple-use-keychain ~/.ssh/id_signing_2026
# Linux: use the gnome-keyring / libsecret SSH agent (started by the desktop session)
# Verify which agent is active:
echo "$SSH_AUTH_SOCK" Hardware tokens are the strongest option — the private key is generated on the device and cannot be extracted, so even a fully compromised laptop cannot leak it. With a verify-required FIDO2 key, each signature needs a physical touch and PIN. Signing a YubiKey-resident key end to end is covered in Signing Git Commits with a YubiKey.
Verify: With the key in the agent, sign without being prompted for a passphrase again:
git commit --allow-empty -m "chore: agent-backed signing" -S && echo "signed via agent" SAFETY WARNING: An unlocked agent will sign anything asked of it. On shared or long-running hosts, set a
max-cache-ttl, lock the screen when away, and runssh-add -D(orgpgconf --kill gpg-agent) to flush keys before stepping away. Never forward your agent (ForwardAgent yes) to an untrusted host — it can use your key while connected.
Step 3 — Set an Expiry and Rotation Policy Jump to heading
Intent: Bound the lifetime of every key and make rotation a scheduled, boring event rather than an emergency.
Pick a rotation interval — twelve months is a sensible default for most teams, shorter for high-value repositories. Encode the policy in three places so it is enforced rather than aspirational:
- The key’s own expiry (GPG
expire, or the SSHvalid-beforewindow in Step 4). - A calendar reminder at expiry minus one month, so rotation happens with overlap rather than after an outage.
- A documented runbook so anyone can perform the rotation.
For GPG, extend or reset the expiry through the key editor:
# Interactive expiry change
gpg --edit-key <LONG_KEY_ID>
# gpg> expire # set new expiry, e.g. 1y
# gpg> save
# Re-export the public key and re-upload it to GitHub/GitLab after changing expiry
gpg --armor --export <LONG_KEY_ID> > signing-2026.pub.asc The rotation itself is deliberately overlapping: generate key B (Step 1) while key A is still active, add B to allowed_signers with a valid-after that starts before A’s valid-before, switch user.signingkey to B, and only then let A expire. During the overlap window both keys verify, so no commit is ever signed by an untrusted key. A full worked emergency rotation — when the trigger is compromise rather than the calendar — lives in Rotating a Compromised Commit-Signing Key.
Verify: Confirm the active key’s expiry is what you expect:
# GPG: the pub line shows [expires: YYYY-MM-DD]
gpg --list-keys --keyid-format=long <LONG_KEY_ID>
# SSH: expiry is enforced by allowed_signers, verified in Step 4 Step 4 — Maintain a Date-Scoped allowed_signers File Jump to heading
Intent: Keep every historically signed commit verifiable across any number of rotations by recording when each key was trusted, not merely that it was.
Git’s SSH signature verification reads a trusted-keys file named by gpg.ssh.allowedSignersFile. Each line is principal [options] key-type key-data. The two options that make rotation safe are valid-after and valid-before: they define the window during which a key’s signatures are trusted, and Git compares each commit’s author timestamp against that window. Sign a commit while a key’s window was open and it verifies forever, even after you retire the key.
# Point Git at a repo-tracked or home-tracked allowed_signers file
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers A date-scoped file spanning one rotation looks like this:
# ~/.config/git/allowed_signers
# principal options key
[email protected] valid-after="20250101000000Z",valid-before="20260101000000Z" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...KEY_A
[email protected] valid-after="20251201000000Z" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...KEY_B Key A is trusted only for commits dated in 2025; key B is trusted from December 2025 onward with an open end. The one-month overlap (20251201 to 20260101) is the rotation window. When you retire key A you do not delete its line — deleting it would make every 2025 commit fail verification. You leave the historical window in place and simply stop signing with A.
For teams, commit the allowed_signers file into the repository (for example at .github/allowed_signers) so verification is reproducible in CI and on every clone. That shared file is also what powers the commit verification gates that reject unsigned or unknown-key commits.
Verify: Check that a commit signed under each window verifies, and that a date outside every window does not:
# Verify the latest commit against the allowed_signers windows
git verify-commit HEAD
git log --show-signature -3
# Standalone check with ssh-keygen, using the same file
ssh-keygen -Y verify -f ~/.config/git/allowed_signers \
-I [email protected] -n git -s /path/to/signature < /path/to/signed-payload
# Expected: Good "git" signature for [email protected] SAFETY WARNING: Do not “clean up”
allowed_signersby removing retired keys — that silently breaks verification of all commits they signed and can make a genuine history look forged. Only ever close a window (add or tightenvalid-before); never delete a historical entry. Keep the file under version control so any change is reviewable.
Step 5 — Revoke a Retired or Compromised Key Jump to heading
Intent: Make an unambiguous, out-of-band statement that a key must no longer be trusted for new signatures, while preserving the validity of commits it legitimately made before the compromise.
Expiry and revocation are different tools. Expiry is a planned end-of-life; revocation is an emergency signal that a key is untrusted, usually because the private half leaked. The two mechanisms differ by backend:
For GPG, publish the revocation certificate you pre-generated in Step 1, then push it to keyservers and re-upload the revoked public key to your forge:
# Import and publish the pre-made revocation certificate
gpg --import ~/revoke-2026.asc
gpg --keyserver hkps://keys.openpgp.org --send-keys <LONG_KEY_ID>
# Re-export so collaborators pull the revoked state
gpg --armor --export <LONG_KEY_ID> > signing-2026-REVOKED.pub.asc For SSH, there is no keyserver. Revocation is expressed by editing allowed_signers: close the compromised key’s window at the moment of compromise so signatures dated after that instant stop verifying, while genuine earlier commits remain valid:
# Compromise discovered 2025-11-15 14:00Z — close the window at that instant
[email protected] valid-after="20250101000000Z",valid-before="20251115140000Z" ssh-ed25519 AAAAC3...KEY_A If you cannot trust any signature from the compromised key (for example, the attacker may have back-dated commits), remove trust entirely by deleting the line — but only after auditing which commits it signed, since those will then fail verification. Deletion is the SSH analogue of a hard revocation. On the forge itself, delete the public key from the account’s SSH/GPG signing-keys settings so the platform’s own “Verified” badge disappears.
Verify: Confirm a signature dated after the revocation point now fails:
# A commit dated after valid-before should no longer verify
git verify-commit <post-compromise-sha>
# Expected: error: unknown/unauthorized signer OR No principal matched SAFETY WARNING: Losing the only copy of a private key means you can never revoke it cleanly with GPG unless you saved the revocation certificate — this is why Step 1 generates it upfront. If you lose the key and have no revocation certificate, you must fall back to the SSH-style approach: close/remove trust in
allowed_signersand rotate to a new key. If the key was compromised (not merely lost), assume the attacker can sign as you until you close the trust window and re-key. Recovery is: revoke → generate a new key → add it toallowed_signers→ notify the team → audit recent commits.
Step 6 — Store Signing Keys for CI Without Leaking Them Jump to heading
Intent: Let pipelines sign tags, release commits, or bot commits without a long-lived private key ever landing in a log, an artifact, or a cached layer.
Use a dedicated bot key, never a human’s personal key — that way revoking CI access never disrupts an engineer, and the allowed_signers file can scope the bot’s principal separately. Store the private half in the platform secret store, load it into an ephemeral agent at job start, and flush it at job end.
# .github/workflows/signed-release.yml
name: Signed release
on:
push:
branches: [main]
jobs:
sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Load bot signing key into an ephemeral agent
env:
# Base64-encoded private key stored as an Actions secret
SIGNING_KEY: $
run: |
eval "$(ssh-agent -s)"
# Never echo the key; write to a 0600 file, add, then delete
umask 077
printf '%s' "$SIGNING_KEY" | base64 -d > /tmp/bot_signing
ssh-add /tmp/bot_signing
shred -u /tmp/bot_signing # remove the on-disk copy immediately
git config gpg.format ssh
git config user.signingkey "$(ssh-add -L | head -1)"
git config commit.gpgsign true
- name: Create a signed release commit
run: |
git commit --allow-empty -S -m "chore(release): signed by CI bot" The equivalent secret exists as a masked CI/CD variable in GitLab, a Vault secret pulled at runtime, or a short-lived credential minted per job. Better still, where the platform supports it, avoid a long-lived key entirely: OIDC-federated, keyless signing (Sigstore gitsign) issues an ephemeral certificate per run so there is no private key to store or rotate. That approach and its CI verification are covered under Verifying Signed Commits in CI Pipelines.
Verify: From a later job or locally, confirm the CI-signed commit verifies against the bot’s allowed_signers entry:
git log --show-signature -1 origin/main
# Expected: Good "git" signature for [email protected] with ED25519 key ... SAFETY WARNING: A secret printed to a build log is compromised even if the log is private — assume any log can be exfiltrated. Never
echo/cata key, disable command tracing (set +x) around key handling, mask the variable, and rotate the bot key on any suspicion of exposure. Restrict which branches and environments can read the signing secret so a fork PR can never mint a signature.
Integration with Adjacent Workflows Jump to heading
Key custody is the foundation that the rest of the supply-chain stack stands on, so it interlocks with two neighbouring workflows.
Choosing the backend determines almost everything above: whether you manage GPG revocation certificates and keyservers or the simpler SSH allowed_signers model, whether hardware tokens are ed25519-sk or GPG smartcards, and how CI loads the key. Work through GPG vs SSH Commit Signing first if you have not committed to one — the storage and rotation mechanics in this guide differ per backend, and mixing them doubles the operational surface.
Enforcement is what makes protected keys worth protecting. A perfectly rotated key is pointless if the server accepts unsigned commits, so pair this custody model with Commit Verification Gates, which reject commits that are unsigned or signed by a key outside the allowed_signers windows. The same date-scoped file you maintain in Step 4 is the trust anchor those gates consult, so a single reviewed change to allowed_signers propagates to every enforcement point at once.
The division of responsibility is clean: this page governs the private key (generation, storage, expiry, rotation, revocation); the verification gates govern the public trust list and the merge-time policy. Keep the private-key operations local or in a hardened secret store, and keep the public allowed_signers file in version control where every change is reviewable.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Old commits show “No principal matched” after rotation | Retired key’s line deleted from allowed_signers instead of window-closed | Restore the historical entry with its original valid-after/valid-before window |
git commit -S hangs or fails silently | Agent not running or key not loaded | ssh-add -l / gpg-connect-agent /bye; re-add the key and retry |
| New key verifies locally but not in CI | CI checks out a stale allowed_signers, or the new key’s valid-after is in the future | Commit the updated file; confirm the valid-after timestamp precedes the commit date |
| Commit dated inside the window still fails | Author date and committer date differ; verification uses the commit timestamp | Check git log --format='%aI %cI'; ensure the relevant timestamp falls in the window |
| GPG key still “valid” after expiry passes | Collaborators cached the pre-expiry public key | Re-export and redistribute the key; run gpg --refresh-keys |
| CI bot signature rejected as unknown signer | Bot principal missing from allowed_signers | Add a scoped ci-bot@… line with an open-ended valid-after |
| Passphrase prompts on every commit | Agent cache TTL too short or agent not persisted across shells | Raise default-cache-ttl; ensure SSH_AUTH_SOCK is exported in your shell profile |
Frequently Asked Questions Jump to heading
Do old signed commits stop verifying after I rotate a signing key? Jump to heading
Not if you use a date-scoped allowed_signers file. Each entry carries valid-after and valid-before options that bound when the key was trusted, and Git compares the commit’s timestamp against those windows. Commits signed while the old key’s window was open keep verifying forever — even after the key retires — as long as you leave the historical entry in place and never delete it.
Should I set an expiry date on a signing key? Jump to heading
Yes. An expiry bounds the damage window of an undetected compromise and turns rotation into a scheduled habit rather than an emergency. For GPG, set it with expire in gpg --edit-key at creation or later; for SSH, encode the lifetime as the valid-before timestamp in allowed_signers. Twelve months is a common default, shorter for high-value repositories. Always rotate with a one-month overlap so the new key is trusted before the old one lapses.
Where should the private signing key live? Jump to heading
Ideally on a hardware token (YubiKey, Nitrokey, or an OS Secure Enclave) so the private half is generated on-device and can never be extracted. Failing that, keep it passphrase-encrypted at rest and load it into ssh-agent or gpg-agent for the session; an OS keychain (macOS Keychain, gnome-keyring, libsecret) is an acceptable middle ground. Never commit a private key to a repository, bake it into a container image, or forward your agent to an untrusted host.
How do I sign commits in CI without exposing the key? Jump to heading
Use a dedicated bot key — never a human’s personal key — stored in the CI secret store (GitHub Actions secrets, GitLab masked variables, or Vault). Load it into an ephemeral agent at job start, write it only to a 0600 file that you shred immediately, and never echo it to logs. Restrict which branches and environments can read the secret. Where the platform supports it, prefer OIDC-based keyless signing so no long-lived private key sits in CI at all.
What is the difference between expiring a key and revoking it? Jump to heading
Expiry is a planned end-of-life encoded in the key (GPG) or in the allowed_signers window (SSH); signatures made before that date still verify. Revocation is an out-of-band statement that a key can no longer be trusted, usually because it was compromised. For GPG you publish a revocation certificate; for SSH you close or remove the key’s trust window at the moment of compromise so later signatures fail while genuine earlier ones survive.
Related Jump to heading
- Commit Signing & Git Supply-Chain Security — the parent overview tying key custody, verification, and CI signing into one supply-chain model
- Rotating a Compromised Commit-Signing Key — the emergency runbook for revoking, re-keying, and re-scoping
allowed_signersafter a leak - GPG vs SSH Commit Signing — choosing the signing backend that dictates your storage, expiry, and revocation mechanics
- Commit Verification Gates — enforcing that only commits signed by a key inside its
allowed_signerswindow can merge - Blocking Secrets with a Pre-Push Scan — catching an accidentally committed private key before it reaches the remote