Migrating a team from GPG to SSH commit signing Jump to heading

GPG commit signing works, but it carries operational weight: agent sockets that expire mid-session, gpg: signing failed: Inappropriate ioctl for device errors on headless CI, keyservers that no longer resolve, and a per-developer keyring that nobody wants to administer. Since Git 2.34, gpg.format ssh lets you sign commits with the same SSH key already used for authentication — no keyring, no agent quirks, one credential per person. The migration is low-risk because commit signatures are immutable: switching your team’s signing format does not touch a single historical commit. The only thing that can break is verification, and only if you let the old GPG public keys fall out of the trust store. This page is a focused recipe within GPG vs SSH Commit Signing, which weighs the two formats in depth; here we execute the cutover.

When to use this approach Jump to heading

Apply this recipe when:

  • Your team already signs commits with GPG and the keyring maintenance — expiry, agent forwarding, subkey confusion — costs more than it protects.
  • Developers already authenticate to the remote over SSH, so every person owns a key you can reuse for signing.
  • CI runners need to sign or verify commits and GPG agent setup on ephemeral runners is fragile.
  • You want signing that survives a laptop rebuild with a single ~/.ssh restore rather than a keyring export/import dance.

This recipe is not the right fit if your organization has a hardware-PKI or corporate GPG smartcard mandate, or if downstream tooling verifies against a WKD/keyserver contract you cannot change. If your concern is protecting the private key material itself rather than the format, start with Protecting & Rotating Signing Keys before changing formats.

The diagram below shows what changes at cutover and what stays fixed: new commits are signed with SSH, old commits keep their GPG signatures, and verification tooling must trust both key sets at once.

GPG to SSH signing migration and the dual trust storeA timeline split at a cutover date: commits before the cutover are GPG-signed, commits after are SSH-signed. Both feed a single verification layer that must trust the exported GPG public keys and the allowed_signers file simultaneously so every commit reports as verified.cutover datehistory: GPG-signedgpg.format = gpg (immutable)new: SSH-signedgpg.format = sshverification layertrusts GPG keyring + allowed_signersgit log --show-signature → Good/Verified

Step-by-step recipe Jump to heading

Step 1 — Inventory current GPG signers and set a cutover date Jump to heading

Before changing anything, record who signs today and with which GPG key. You will need these public keys to keep history verifiable after the switch.

# List every secret signing key on your machine (developers run this individually)
gpg --list-secret-keys --keyid-format=long

# Export each signer's PUBLIC key — this is what verification needs, never the private key
gpg --armor --export <LONG_KEY_ID> > gpg-pub-<username>.asc

Verify each exported key is a public block and parses cleanly:

# Must print "public key" — not "secret key"
gpg --show-keys gpg-pub-<username>.asc | head -1

Collect these .asc files centrally. Pick a single cutover date so history has a clean GPG-before / SSH-after boundary.

Step 2 — Generate or nominate an SSH signing key per developer Jump to heading

Any modern SSH key can sign commits. Reuse an existing ed25519 authentication key or mint a dedicated signing key — a separate key limits blast radius if one is exposed.

# Generate a dedicated signing key (recommended over reusing the auth key)
ssh-keygen -t ed25519 -C "sig-$(whoami)@$(hostname)" -f ~/.ssh/id_ed25519_signing

Verify the public key exists and is a valid ed25519 key:

# Should print the key type and comment on one line
ssh-keygen -l -f ~/.ssh/id_ed25519_signing.pub

Step 3 — Switch gpg.format to ssh and point signingkey at the public key Jump to heading

This is the actual format flip. gpg.format ssh tells Git to invoke ssh-keygen -Y sign instead of GPG, and — critically for SSH — user.signingkey must point at the public key file (or its literal key:: contents), not a GPG key ID.

# Set signing format to SSH
git config --global gpg.format ssh

# Point at the PUBLIC key file. For SSH, this is a filesystem path, not a key ID.
git config --global user.signingkey ~/.ssh/id_ed25519_signing.pub

# Sign every commit and annotated tag by default
git config --global commit.gpgsign true
git config --global tag.gpgsign true

SAFETY WARNING: A mis-set user.signingkey silently disables signing — Git does not error when the key format and gpg.format disagree; it just produces unsigned commits that look normal until an audit finds them. After changing these values, immediately make a test commit and confirm it is signed (Step 7). To retroactively fix a range of accidentally unsigned commits, re-sign them with git rebase --exec 'git commit --amend --no-edit -S' <base> on a branch you control before it reaches a protected branch.

Verify all three values landed:

# Expect: ssh / a .pub path / true
git config --get gpg.format
git config --get user.signingkey
git config --get commit.gpgsign

Step 4 — Build an allowed_signers file that preserves history Jump to heading

For SSH signatures, Git verifies against gpg.ssh.allowedSignersFile — a file mapping an identity (email) to a public key. This is where you keep both the new SSH keys and the trust needed for GPG-era commits. Each line is <principal> <key-type> <key-data>; the principal should match the commit author’s email.

# Create a repo-tracked allowed_signers file
cat > .gitsign/allowed_signers <<'EOF'
# --- Active SSH signers (post-cutover) ---
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...alice
[email protected]   ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...bob
# Rotated-out but still-valid keys stay listed so their commits verify:
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...alice-old
EOF

# Point Git at it
git config --global gpg.ssh.allowedSignersFile "$(pwd)/.gitsign/allowed_signers"

Historical commits keep GPG signatures, and those are not verified through allowed_signers — they are verified through the GPG keyring. So the second half of preserving history is retaining the exported GPG public keys from Step 1 in each verifier’s keyring (and on the platform):

# Import every archived GPG public key so old signatures still resolve to "Good"
for f in gpg-pub-*.asc; do gpg --import "$f"; done

Verify the allowed_signers file is well-formed by test-verifying a fresh SSH signature:

# Sign a throwaway string and verify against the file — expect "Good ... signature"
echo test | ssh-keygen -Y sign -f ~/.ssh/id_ed25519_signing -n git > /tmp/t.sig
echo test | ssh-keygen -Y verify -f .gitsign/allowed_signers \
  -I "$(git config user.email)" -n git -s /tmp/t.sig

Step 5 — Register SSH signing keys on the hosting platform Jump to heading

The remote shows the Verified badge only if it holds the public key with signing usage. On GitHub and GitLab an SSH key added for authentication does not count for signing — it must be added again with the signing type.

# GitHub, via the gh CLI: add the SSH key as a SIGNING key (distinct from auth)
gh ssh-key add ~/.ssh/id_ed25519_signing.pub --type signing --title "signing-$(whoami)"

# Confirm it is registered with the signing purpose
gh api user/ssh_signing_keys --jq '.[].title'

Keep the old GPG public keys uploaded to the platform too — removing them turns previously green commits grey. On GitLab, add the key under SSH Keys → Usage type: Signing; retain existing GPG keys under GPG Keys.

Step 6 — Roll out via a shared .gitconfig Jump to heading

Distribute the format switch as a tracked, includable config so every developer applies identical settings and no one is left on a silent-unsigned state. Ship a repo file and have each person include it once.

# .gitconfig.shared  (tracked in the repo root)
[gpg]
	format = ssh
[gpg "ssh"]
	allowedSignersFile = .gitsign/allowed_signers
[commit]
	gpgsign = true
[tag]
	gpgsign = true

Each developer wires it in once — user.signingkey stays personal (their own .pub path), everything else comes from the shared file:

# One-time per clone: include the shared config and set the personal key
git config --local include.path ../.gitconfig.shared
git config --global user.signingkey ~/.ssh/id_ed25519_signing.pub

Verify the include resolved and the effective values come from the shared file:

# --show-origin proves which file each value came from
git config --show-origin --get gpg.format
git config --show-origin --get gpg.ssh.allowedSignersFile

Fold this include step into your existing post-clone bootstrap so new contributors are covered automatically. If you gate merges on signatures, coordinate the cutover date with Commit Verification Gates so the branch-protection rule flips to accept SSH signatures at the same moment developers start producing them.

Step 7 — Verify old and new commits with git log --show-signature Jump to heading

The migration is correct only if a single git log run shows GPG-era commits as Good and post-cutover commits as verified with your SSH principal.

# Make a signed test commit on a scratch branch
git commit --allow-empty -m "test: ssh signing" && git log --show-signature -1

Expected output for the new commit references SSH and your email as the matched principal:

Good "git" signature for [email protected] with ED25519 key SHA256:...

Now walk back across the cutover and confirm nothing regressed:

# Both formats should verify in one pass — GPG "Good signature" for old, SSH "Good" for new
git log --show-signature --format='%h %G? %s' -20

The %G? placeholder prints G for a good signature, U for good-but-unknown-trust, B for bad, and N for none. A healthy migrated history shows G (or U) across the cutover boundary and never N on commits authored after Step 3.

Validation checklist Jump to heading

Before declaring the migration complete, confirm each item:

Frequently asked questions Jump to heading

Will switching to SSH signing invalidate our old GPG-signed commits? Jump to heading

No. The signature is baked into each historical commit object and is immutable — rewriting it would change the commit SHA. Those commits remain GPG-signed forever. Verification only breaks if you stop maintaining the GPG public keys that Git consults, so keep the exported keys in every verifier’s keyring and on the hosting platform. With the keys retained, git log --show-signature continues to report pre-cutover commits as Good signature long after the team stops producing GPG signatures.

Why does git say “No signature” after I set user.signingkey? Jump to heading

Almost always a format mismatch. If gpg.format is still gpg while user.signingkey now points at an SSH .pub path, Git tries to hand the SSH path to GPG, fails silently, and produces an unsigned commit — it does not raise an error. The other common cause is commit.gpgsign being unset, so nothing is signed at all. Run git config --get gpg.format and confirm it returns ssh, and that commit.gpgsign is true. This silent-failure mode is exactly why Step 3 pairs the change with an immediate signed-commit check.

Do we need an allowed_signers file if the platform already verifies signatures? Jump to heading

Yes, for local and CI verification. GitHub and GitLab verify against the keys you upload and render a Verified badge in their UI, but git verify-commit and git log --show-signature run entirely locally and consult gpg.ssh.allowedSignersFile. Without that file, verifying an SSH-signed commit on a developer machine or CI runner reports No principal matched even though the platform shows it as verified. If your CI enforces signatures, ship the allowed_signers file to the runner and point gpg.ssh.allowedSignersFile at it.

  • GPG vs SSH Commit Signing — the parent page comparing the two signing formats, their trust models, and when each is the right default before you commit to a migration.
  • Signing Git Commits with a YubiKey — keep signing keys in hardware; pairs with SSH signing via a resident sk-ed25519 key for tamper-resistant private material.
  • Commit Verification Gates — enforce that only verified, signed commits reach protected branches, and time the rule change to your cutover date.
  • Protecting & Rotating Signing Keys — how to store, rotate, and retire the SSH keys this migration introduces without breaking historical verification.