Setting up and rotating commit signing keys with the GitHub API

GitHub needs our public signing key to show our commits and tags as “Verified”,1 and to accept them on branches that require signed commits.2 In this article, we will use the GitHub API to set up and rotate our signing key, then verify signed tags and commits on GitHub and locally. We need only one signing key, SSH or GPG, and the article covers both.

Preparation: tokens and request headers

  1. Create a fine-grained personal access token for each key type, under Settings > Developer settings > Personal access tokens > Fine-grained tokens.3 Each token gets the repository access “Public repositories (read-only)” and only one account permission, “SSH signing keys” or “GPG keys”, set to read and write.4

  2. Define the request headers:

    SSH_HEADERS=(
      --header 'Authorization: Bearer github_pat_1234567890abcdefghijkl_mnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321zyxwvutsr'
      --header 'Accept: application/vnd.github+json'
      --header 'X-GitHub-Api-Version: 2026-03-10'
    )
    
    GPG_HEADERS=(
      --header 'Authorization: Bearer github_pat_0987654321zyxwvutsrqpo_mlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA1234567890abcdefghij'
      --header 'Accept: application/vnd.github+json'
      --header 'X-GitHub-Api-Version: 2026-03-10'
    )
    

SSH signing keys

Publishing a new SSH signing key

  1. Upload the new public key. GitHub answers with HTTP 201:

    jq --null-input --rawfile key ~/.ssh/<new-key>.pub '{title: "<key-title>", key: $key}' \
      | curl --silent "${SSH_HEADERS[@]}" --request POST --data @- \
        https://api.github.com/user/ssh_signing_keys
    
  2. List the signing keys and find the old key’s id:

    curl --silent "${SSH_HEADERS[@]}" https://api.github.com/user/ssh_signing_keys \
      | jq '.[] | {id, title, created_at}'
    
  3. Deleting the old key is unnecessary, and I would advise against it. GitHub keeps a signature verified once it has checked it,5 and it seems to check a commit when it is pushed, but a tag only when someone first reads it through the API (this is not documented). So a tag signed with the old key and not read before the delete loses its “Verified” mark. The key also disappears from the public list, so anyone who builds an allowed signers file from that list can no longer check our old signatures locally (see “Checking SSH signatures”). If we delete the old key anyway, GitHub answers with HTTP 204:

    curl --silent "${SSH_HEADERS[@]}" --request DELETE \
      --write-out '%{http_code}\n' https://api.github.com/user/ssh_signing_keys/<id>
    

GitHub stores the key without its comment. Uploading a key that is already on the account fails with HTTP 422 and the error message “key is already in use”.

One key for login and signing, or two

We use a separate SSH key for signing, not the key we log in with.

GitHub accepts the same SSH key as a login key and as a signing key, and keeps the two lists separate.5 Reusing the key does not let a login signature pass as a commit signature. SSH signatures carry a namespace, which is “used to prevent signature confusion across different domains of use”.6 Git signs in the namespace git, which git tag --verify names in its output Good "git" signature.

With one key for both, a retired login key has to stay in the signing key list, or our old signatures stop verifying (see step 3 of “Publishing a new SSH signing key”).

Setting the SSH key in Git

Sign with the new SSH key:

git config --global gpg.format ssh
git config --global user.signingKey ~/.ssh/<new-key>.pub

GPG keys

Unlike with SSH signing keys, rotating the GPG subkey just means adding a new subkey to the same primary key. We replace the key’s entry on GitHub, and Git’s settings stay the same.

Publishing the updated GPG key

  1. Export the public key with all subkeys, expired ones included:

    gpg --armor --export <primary-key-fingerprint> > pubkey.asc
    gpg --show-keys --keyid-format=long --list-options show-unusable-subkeys pubkey.asc
    
  2. Find the current entry’s id:

    curl --silent "${GPG_HEADERS[@]}" https://api.github.com/user/gpg_keys \
      | jq '.[] | {id, key_id, subkeys: [.subkeys[] | {key_id, expires_at}]}'
    
  3. Delete the entry. GitHub answers with HTTP 204:

    curl --silent "${GPG_HEADERS[@]}" --request DELETE \
      --write-out '%{http_code}\n' https://api.github.com/user/gpg_keys/<id>
    

    We delete first because the API has no update call,7 and uploading a key that is already on the account fails with HTTP 422 and the error message “The key was not added because one or more subkeys already exist”.

  4. Upload the export. GitHub answers with HTTP 201:

    jq --null-input --rawfile key pubkey.asc '{name: "<key-name>", armored_public_key: $key}' \
      | curl --silent "${GPG_HEADERS[@]}" --request POST --data @- \
        https://api.github.com/user/gpg_keys
    
  5. Run step 2 again: the entry now lists every subkey with its expires_at.

Setting the GPG key in Git

Sign with the GPG key:

git config --global gpg.format openpgp
git config --global user.signingKey <primary-key-fingerprint>

gpg.format openpgp undoes the SSH section’s gpg.format ssh. Without it, GPG signing fails.

Signing commits and tags

  1. Sign every commit and tag:

    git config --global commit.gpgSign true
    git config --global tag.gpgSign true
    

    We sign commits and not only tags, because GitHub judges each commit by its own signature. The signature belongs to the tag alone: a signed tag on an unsigned commit shows verified: true and passes git verify-tag, while the commit shows verified: false and fails git verify-commit. A signed tag is a ref to a signed tag object that holds the commit’s hash. Changing an older commit gives it and every commit after it a new hash, and the tag still points to the old one, so for a signed tag on the new history we set the tag on the new commit and sign it again.

    With commit.gpgSign true, a rebase, --amend and cherry-pick sign every commit they rewrite.

  2. Sign and push a release tag:

    git tag --sign v1.2.0 --message "v1.2.0"
    git push origin v1.2.0
    

Checking signatures

Checking SSH signatures

Locally, Git checks SSH signatures against an allowed signers file. We can fill it with anyone’s public signing keys from GitHub, without logging in:8

curl --silent https://api.github.com/users/<username>/ssh_signing_keys \
  | jq --raw-output '.[] | "<email> \(.key)"' > ~/.config/git/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
git tag --verify v1.2.0

Signing a tag works without an allowed signers file, but verifying it fails with the error message “gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification”. With the signing key listed, git tag --verify prints Good "git" signature for <email> with ED25519 key SHA256:... and exits with 0. After a rotation, a tag signed with the new key fails with the error message “No principal matched” until we add the new key to the file. Removing the old key from the file makes tags signed with the old key fail the same way.

On GitHub, a commit or tag signed with a listed key shows as “Verified” when its committer or tagger email is verified on the account. For a commit, the API returns the status in the field commit.verification of GET /repos/{owner}/{repo}/commits/{sha}; for a tag, in the field verification of GET /repos/{owner}/{repo}/git/tags/{tag-object-sha}. The web UI shows the signing key’s fingerprint, the same one ssh-keygen -l prints for the key.

Checking GPG signatures

Locally, after one import of the public key from GitHub, Git checks old and new signatures:7

curl --silent https://api.github.com/users/<username>/gpg_keys \
  | jq --raw-output '.[].raw_key' | gpg --import
git tag --verify v1.2.0
git verify-commit <commit>

On GitHub, tags and commits signed with the previous subkey still show as “Verified” after the delete and re-upload.95

References


  1. GitHub Docs, “About commit signature verification”, section “Default statuses”, https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#default-statuses ↩︎

  2. GitHub Docs, “About protected branches”, section “Require signed commits”, https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits ↩︎

  3. GitHub Docs, “Managing your personal access tokens”, section “Creating a fine-grained personal access token”, https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token ↩︎

  4. GitHub Docs, “Permissions required for fine-grained personal access tokens”, https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens ↩︎

  5. GitHub Docs, “About commit signature verification”, https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification ↩︎ ↩︎ ↩︎

  6. OpenSSH manual, ssh-keygen(1), option -Y sign, https://man.openbsd.org/ssh-keygen ↩︎

  7. GitHub Docs, “REST API endpoints for GPG keys”, https://docs.github.com/en/rest/users/gpg-keys ↩︎ ↩︎

  8. GitHub Docs, “REST API endpoints for SSH signing keys”, https://docs.github.com/en/rest/users/ssh-signing-keys ↩︎

  9. GitHub Docs, “Adding a GPG key to your GitHub account”, section “About addition of GPG keys to your account”, https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account#about-addition-of-gpg-keys-to-your-account ↩︎