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
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
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
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_keysList 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}'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
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.ascFind 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}]}'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”.
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_keysRun 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
Sign every commit and tag:
git config --global commit.gpgSign true git config --global tag.gpgSign trueWe 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: trueand passesgit verify-tag, while the commit showsverified: falseand failsgit 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,--amendandcherry-picksign every commit they rewrite.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
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 ↩︎
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 ↩︎
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 ↩︎
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 ↩︎
GitHub Docs, “About commit signature verification”, https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification ↩︎ ↩︎ ↩︎
OpenSSH manual, ssh-keygen(1), option
-Y sign, https://man.openbsd.org/ssh-keygen ↩︎GitHub Docs, “REST API endpoints for GPG keys”, https://docs.github.com/en/rest/users/gpg-keys ↩︎ ↩︎
GitHub Docs, “REST API endpoints for SSH signing keys”, https://docs.github.com/en/rest/users/ssh-signing-keys ↩︎
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 ↩︎