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.

Signing key lifecycle and allowed_signers trust windowsA horizontal timeline showing key A generated then active, overlapping with key B during rotation, then key A expiring while key B stays active. Below the timeline, two allowed_signers windows show key A trusted from its valid-after to valid-before date, and key B trusted from its valid-after onward, so historical commits stay verifiable.key Agenerateactiveexpirekey Bgenerateactiveoverlapallowed_signerskey A: valid-after 2025-01 → valid-before 2026-01key B: valid-after 2025-12 → (open)Commits signed inside a window stay verifiable after the key retires.

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-keys output) to a repository, a Docker image, or a Gist. Add id_* and *.gpg patterns 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 run ssh-add -D (or gpgconf --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:

  1. The key’s own expiry (GPG expire, or the SSH valid-before window in Step 4).
  2. A calendar reminder at expiry minus one month, so rotation happens with overlap rather than after an outage.
  3. 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_signers by 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 tighten valid-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_signers and 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 to allowed_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/cat a 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

SymptomLikely causeFix
Old commits show “No principal matched” after rotationRetired key’s line deleted from allowed_signers instead of window-closedRestore the historical entry with its original valid-after/valid-before window
git commit -S hangs or fails silentlyAgent not running or key not loadedssh-add -l / gpg-connect-agent /bye; re-add the key and retry
New key verifies locally but not in CICI checks out a stale allowed_signers, or the new key’s valid-after is in the futureCommit the updated file; confirm the valid-after timestamp precedes the commit date
Commit dated inside the window still failsAuthor date and committer date differ; verification uses the commit timestampCheck git log --format='%aI %cI'; ensure the relevant timestamp falls in the window
GPG key still “valid” after expiry passesCollaborators cached the pre-expiry public keyRe-export and redistribute the key; run gpg --refresh-keys
CI bot signature rejected as unknown signerBot principal missing from allowed_signersAdd a scoped ci-bot@… line with an open-ended valid-after
Passphrase prompts on every commitAgent cache TTL too short or agent not persisted across shellsRaise 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.