Publishing a rotated GPG signing subkey to GitHub through the API
Summary
In this article we will explore how we can make it easier to rotate keys we use for signing commits, using the GitHub API.
Why upload the signing key to GitHub
- Signing works without GitHub: the signature is in the commit or tag, and anyone with the public key can check it locally with
git tag --verifyorgit log --show-signature. - Without the key on the account, GitHub shows signed commits as “Unverified”: “The commit is signed but the signature could not be verified.”1
- A branch that requires signed commits rejects them: “contributors and bots can only push commits that have been signed and verified to the branch”.2
- An uploaded key is published in 1 fixed place tied to the account, readable without logging in:
/users/<username>/ssh_signing_keysor/users/<username>/gpg_keys(see the next section). - Unsigned commits show no status, unless vigilant mode is on, which marks them “Unverified” too.3
Where others find the key
Anyone can list an account’s public SSH signing keys without logging in:4
curl --silent https://api.github.com/users/<username>/ssh_signing_keysThe same for GPG keys:5
curl --silent https://api.github.com/users/<username>/gpg_keysThe GPG keys in armored form, ready for
gpg --import:curl --silent https://github.com/<username>.gpg
Note: commits made in GitHub’s web editor are signed with GitHub’s own key, not the user’s.6
Preparation: tokens and request headers
Create a fine-grained personal access token for each key type, with repository access “Public repositories (read-only)” and only 1 account permission, read and write:7
manage-ssh-signing-keys: “SSH signing keys”.manage-gpg-keys: “GPG keys”.
Create the token files, readable only by you:
install --directory --mode=700 ~/.config/github-tokens install --mode=600 /dev/null ~/.config/github-tokens/manage-ssh-signing-keys install --mode=600 /dev/null ~/.config/github-tokens/manage-gpg-keysPut 1 line in each with an editor, with that file’s token:
Authorization: Bearer github_pat_1234567890abcdefghijkl_mnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321zyxwvutsrDefine the request headers; curl reads the token from the file, so it never appears in a command line:
SSH_HEADERS=( --header @"$HOME/.config/github-tokens/manage-ssh-signing-keys" --header 'Accept: application/vnd.github+json' --header 'X-GitHub-Api-Version: 2022-11-28' ) GPG_HEADERS=( --header @"$HOME/.config/github-tokens/manage-gpg-keys" --header 'Accept: application/vnd.github+json' --header 'X-GitHub-Api-Version: 2022-11-28' )Delete a token file once it no longer serves its purpose.
SSH signing keys
Publishing a new SSH signing key
Upload the new public key (expect 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}'Optional: delete the old key (expect HTTP 204). Only signatures GitHub has already verified stay verified: “previously verified commits retain their verified status”.6 A tag signed with the old key that nobody viewed before the delete showed no “Verified” badge (tested 2026-10-01). Keep the old key on the account, or delete it only after GitHub has shown the old tags as verified.
curl --silent "${SSH_HEADERS[@]}" --request DELETE \ --write-out '%{http_code}\n' https://api.github.com/user/ssh_signing_keys/<id>Test results (2026-10-02, a new commit per case, status read through the API):
- Signed commits are checked at push: a commit signed with the old key, pushed, and first read after the delete was still
verified: true. - Signed tags are checked when first requested: a tag signed with the old key, pushed, and first read after the delete was
verified: false,reason: "unknown_key". - Commits and tags already read before the delete stayed
verified: true. - A signed tag on an unsigned commit is
verified: true; the commit itself isverified: false,reason: "unsigned". - The same cases with GPG stayed
verified: true, because the re-uploaded entry still holds the old subkey.
- Signed commits are checked at push: a commit signed with the old key, pushed, and first read after the delete was still
Test results (2026-09-29, throwaway ed25519 key):
- The upload returns HTTP 201 with
id,key,titleandcreated_at. - GitHub drops the key’s comment (the text after the key, often an email address) and stores only the key type and the key.
- Uploading a key that is already on the account fails with HTTP 422, “key is already in use”.
- The public list (
/users/<username>/ssh_signing_keys, no token) shows the sameid,key,titleandcreated_at. - The delete returns HTTP 204; the key disappears from the account’s own list at once.
- The public list keeps showing a deleted key for up to 60 seconds: its response is cached (
cache-control: public, max-age=60).
Side note: 1 key for login and signing, or 2 separate keys:
- GitHub accepts the same SSH key as a login key and as a signing key; the 2 lists are separate.6
- A login signature cannot pass as a commit signature: SSH signatures carry a namespace “used to prevent signature confusion across different domains of use”8, and Git signs in the namespace
git(git tag --verifyprintsGood "git" signature, tested 2026-09-29). - NIST: “In general, a single key shall be used for only one purpose (e.g., encryption, integrity authentication, key wrapping, random bit generation, or digital signatures)”9; logging in and signing commits are both digital signatures, so this rule alone does not forbid 1 key for both.
- The same section’s reason 2 still applies: “Limiting the use of a key limits the damage that could be done if the key is compromised.”9
- Old signing keys stay in the allowed signers file for good, or old tags fail (tested: “No principal matched.”); with 1 key for both, every retired login key stays there too.
- This article uses a separate signing key.
Signing with the SSH key
Sign every commit and tag with the new SSH key:
git config --global gpg.format ssh git config --global user.signingKey ~/.ssh/<new-key>.pub git config --global commit.gpgSign true git config --global tag.gpgSign trueSign and push a release tag:
git tag --sign v1.2.0 --message "v1.2.0" git push origin v1.2.0
Test results, rebasing with commit.gpgSign true (local, throwaway SSH and GPG keys, same results for both):
- A plain rebase signs every rewritten commit, including one that was unsigned before.
- An interactive rebase with squash and reword signs every resulting commit.
git commit --amendandgit cherry-picksign the new commit.- With
commit.gpgSign false, a rebase that rewrites commits drops their signatures. git rebase --gpg-signsigns the rewritten commits even withcommit.gpgSign false.
Checking SSH signatures
Locally, Git checks SSH signatures against an allowed signers file that lists every key, old and new:
echo "<email> $(cat ~/.ssh/<new-key>.pub)" >> ~/.config/git/allowed_signers git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers git tag --verify v1.2.0On GitHub: the tag shows as “Verified”.
An older tag signed with the previous key still shows as “Verified”.
Test results (2026-09-29, Git 2.55.0, 2 throwaway ed25519 keys, repository-level config instead of --global):
- Signing a tag works without an allowed signers file; verifying it does not: “gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification”.
- With the signing key listed,
git tag --verifyprintsGood "git" signature for <email> with ED25519 key SHA256:...and exits with 0. - After a rotation, a tag signed with the new key fails until the new key is added to the file: “No principal matched.”, exit code 1.
- With both keys listed, old and new tags both verify.
- Removing the old key from the file makes the old tag fail again (“No principal matched.”, exit code 1), so the file must keep every key ever used.
Test results on GitHub (2026-09-30, 2 throwaway signing keys, private test repository, tagger email verified on the account):
- A commit and tag signed with the old key show as “Verified”; the API returns
verified: true,reason: "valid"(commit:GET /repos/{owner}/{repo}/commits/{sha}, fieldcommit.verification; tag:GET /repos/{owner}/{repo}/git/tags/{tag-object-sha}, fieldverification). - The web UI shows the signing key’s fingerprint, the same as
ssh-keygen -lprints for the key. - After uploading the new key and deleting the old one, the old commit and tag stay
verified: truewith the sameverified_at. - A commit and tag signed with the new key show as “Verified”.
- The tag’s
verified_atwas the time of the first API request, not the push; my guess is that GitHub checks a signature the first time someone asks for it (not documented).
GPG keys
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 (expect HTTP 204):
curl --silent "${GPG_HEADERS[@]}" --request DELETE \ --write-out '%{http_code}\n' https://api.github.com/user/gpg_keys/<id>Why delete first: the API has no update call, and uploading a key that is already on the account fails with HTTP 422, “The key was not added because one or more subkeys already exist” (tested 2026-09-28). The re-uploaded entry still holds the old subkeys, so tags signed with them verify (tested 2026-10-01).106
Upload the export (expect 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 lists every subkey, old and new, with its
expires_at.
Signing with the GPG key
Sign every commit and tag with the GPG key; point Git at the primary key, and GnuPG picks the newest valid signing subkey, so this does not change after a rotation:
git config --global gpg.format openpgp git config --global user.signingKey <primary-key-fingerprint> git config --global commit.gpgSign true git config --global tag.gpgSign true
Checking GPG signatures
Locally, import the public key from GitHub; it carries every subkey, old and new, so 1 import checks old and new signatures:
curl --silent https://github.com/<username>.gpg | gpg --import git tag --verify v1.2.0 git verify-commit <commit>On GitHub: the tag and its commit show as “Verified”.
An older tag signed with the previous subkey still shows as “Verified”.
Test results (2026-10-01 and 2026-10-02, throwaway GPG key, fresh empty keyring, private test repository):
- Right after the upload, the import from
https://github.com/<username>.gpgreturned the key (“imported: 1”). git tag --verifyprinted “Good signature” and exited with 0 for tags signed with the old and the new subkey;git verify-commitdid the same for signed commits.- GnuPG adds
[unknown]after the key’s name: the fresh keyring has no trust information about the key; the signature itself checked as good. - On GitHub, tags and commits signed with the old subkey stayed
verified: trueafter the delete and re-upload; those signed with the new subkey wereverified: truetoo.
What this does not do
- It does not sign release downloads (tarballs, checksum files), which most large projects also sign.1112
- Sigstore signs without long-lived keys13; this article keeps the key under the author’s control instead.
Terms
primary key
Used in: GPG keys
subkey
Used in: GPG keys
fingerprint
Used in: Publishing the updated GPG key
key ID
Used in: Publishing the updated GPG key
allowed signers file
Used in: Checking SSH signatures
signed tag
Used in: Signing with the SSH key
verified
Used in: Checking SSH signatures
fine-grained personal access token
Used in: Preparation: tokens and request headers
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, “About commit signature verification”, section “Statuses with vigilant mode enabled”, https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#statuses-with-vigilant-mode-enabled ↩︎
GitHub Docs, “REST API endpoints for SSH signing keys”, https://docs.github.com/en/rest/users/ssh-signing-keys ↩︎
GitHub Docs, “REST API endpoints for GPG keys”, https://docs.github.com/en/rest/users/gpg-keys ↩︎
GitHub Docs, “About commit signature verification”, https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification ↩︎ ↩︎ ↩︎ ↩︎
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 ↩︎
OpenSSH manual, ssh-keygen(1), option
-Y sign, https://man.openbsd.org/ssh-keygen ↩︎NIST SP 800-57 Part 1 Rev. 5, “Recommendation for Key Management: Part 1 - General”, section 5.2 “Key Usage”, https://doi.org/10.6028/NIST.SP.800-57pt1r5 ↩︎ ↩︎
GitHub Enterprise Server 3.0 Docs, “Updating an expired GPG key”, https://docs.github.com/en/enterprise-server@3.0/authentication/troubleshooting-commit-signature-verification/updating-an-expired-gpg-key ↩︎
Node.js, “Node.js release process”, https://github.com/nodejs/node/blob/main/doc/contributing/releases.md ↩︎
Apache Software Foundation, “Release signing”, https://infra.apache.org/release-signing.html#keys-policy ↩︎
Python, “PEP 761: Deprecating PGP signatures for CPython artifacts”, https://peps.python.org/pep-0761/ ↩︎