Publishing a rotated GPG signing subkey to GitHub through the API

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 --verify or git 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_keys or /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

  1. Anyone can list an account’s public SSH signing keys without logging in:4

    curl --silent https://api.github.com/users/<username>/ssh_signing_keys
    
  2. The same for GPG keys:5

    curl --silent https://api.github.com/users/<username>/gpg_keys
    
  3. The 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

  1. 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”.
  2. 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-keys
    
  3. Put 1 line in each with an editor, with that file’s token:

    Authorization: Bearer github_pat_1234567890abcdefghijkl_mnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321zyxwvutsr
    
  4. Define 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'
    )
    
  5. Delete a token file once it no longer serves its purpose.

SSH signing keys

Publishing a new SSH signing key

  1. 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_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. 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 is verified: false, reason: "unsigned".
    • The same cases with GPG stayed verified: true, because the re-uploaded entry still holds the old subkey.

Test results (2026-09-29, throwaway ed25519 key):

  • The upload returns HTTP 201 with id, key, title and created_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 same id, key, title and created_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 --verify prints Good "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

  1. 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 true
    
  2. Sign 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 --amend and git cherry-pick sign the new commit.
  • With commit.gpgSign false, a rebase that rewrites commits drops their signatures.
  • git rebase --gpg-sign signs the rewritten commits even with commit.gpgSign false.

Checking SSH signatures

  1. 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.0
    
  2. On GitHub: the tag shows as “Verified”.

  3. 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 --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 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}, field commit.verification; tag: GET /repos/{owner}/{repo}/git/tags/{tag-object-sha}, field verification).
  • The web UI shows the signing key’s fingerprint, the same as ssh-keygen -l prints for the key.
  • After uploading the new key and deleting the old one, the old commit and tag stay verified: true with the same verified_at.
  • A commit and tag signed with the new key show as “Verified”.
  • The tag’s verified_at was 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

  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 (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

  4. 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_keys
    
  5. Run step 2 again: the entry lists every subkey, old and new, with its expires_at.

Signing with the GPG key

  1. 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

  1. 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>
    
  2. On GitHub: the tag and its commit show as “Verified”.

  3. 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>.gpg returned the key (“imported: 1”).
  • git tag --verify printed “Good signature” and exited with 0 for tags signed with the old and the new subkey; git verify-commit did 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: true after the delete and re-upload; those signed with the new subkey were verified: true too.

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


  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, “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 ↩︎

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

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

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

  7. 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 ↩︎

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

  9. 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 ↩︎ ↩︎

  10. 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 ↩︎

  11. Node.js, “Node.js release process”, https://github.com/nodejs/node/blob/main/doc/contributing/releases.md ↩︎

  12. Apache Software Foundation, “Release signing”, https://infra.apache.org/release-signing.html#keys-policy ↩︎

  13. Python, “PEP 761: Deprecating PGP signatures for CPython artifacts”, https://peps.python.org/pep-0761/ ↩︎