SSH keys answer one question: may this machine push to the repo? PGP keys answer a completely different one: who actually wrote this commit?
Git stores author name and email as plain text. Anyone can set user.email to [email protected] and commit as him — Git won't stop them. PGP signing fixes that by attaching a cryptographic signature that only the holder of a specific private key can produce, and GitHub/GitLab verify that signature before stamping the commit "Verified."
Short version: generate a GPG keypair → keep the private key local → paste the public key into GitHub/GitLab settings → sign with git commit -S → the platform verifies the signature and shows a badge.
This is the companion post to GitHub and GitLab SSH Keys — that one gets you access, this one gets you identity.
Core Concepts
Asymmetric crypto, again
Same foundation as SSH: a mathematically linked key pair.
| Key | Role in PGP | Analogy |
|---|---|---|
| Private key | Signs commits, stays on your machine, passphrase-protected | SSH private key |
| Public key | Uploaded to the platform, used to verify signatures | Key in authorized_keys |
Different algorithm stack (OpenPGP / RFC 4880 instead of the SSH wire protocol), different tool (gpg instead of ssh-keygen), but the trust model is identical: prove possession of the private key, verify with the public key.
Signing vs. encryption
PGP does two jobs. Git only uses one:
- Signing — produce a signature over data so others can verify origin and integrity. This is what
git commit -Sdoes. - Encryption — scramble data so only key holders can read it. Used for email (Enigmail, GPGME), file exchange, password managers. Git never touches this.
When someone says "PGP keys for GitHub," they mean signing keys. Encryption capability rides along in the same key pair but sits unused for git work.
Key anatomy
One GPG "key" is actually a structure:
Primary key (certification, usually Ed25519 or RSA)
├── Subkey: signing (Ed25519)
├── Subkey: encryption (Curve25519 or RSA)
└── User ID: Name <[email protected]>
A few things worth knowing before you generate one:
- Fingerprint — a 40-hex-character SHA-1 hash of the public key, displayed as five groups of four. This is what you compare when verifying someone's key out-of-band.
- Expiry — set at generation (
gpg --quick-set-expire) or later. An expired key's old commits typically still show as verified historically, with the expiry noted in the badge detail. - Revocation — a formal "this key is dead" certificate. Different from expiry: revocation signals compromise. GitHub and GitLab handle it differently — more on that below.
- Subkeys — sign with the subkey, keep the primary key offline. Standard practice for high-security setups, not required for daily git use.
What a signed commit actually proves
Three claims, no more:
- Authorship — the holder of the private key created this signature over this exact commit object.
- Integrity — commit content (tree, parents, message, timestamps) hasn't changed since signing. One flipped byte breaks verification.
- Binding to identity — the platform ties the key to your account if your committer email matches a verified email on the key.
What it does not prove:
- The code is correct or safe — the signature covers "who," not "what."
- The key holder reviewed the changes — they signed whatever was staged.
- The email in the commit belongs to a real person at a company — only that someone with that key signed it.
Platform keyring, not web of trust
Classic PGP culture publishes keys to keyservers and builds a trust graph through cross-signatures. GitHub and GitLab ignore all of that. Both maintain private keyrings containing only keys users have uploaded to their own accounts. The verification path looks like this:
Signature on commit
→ extract key ID
→ look up in platform account keyring
→ found? check signature validity
→ check committer email against key's verified emails
→ badge
No keyserver query, no web of trust. Key not on your account = UNKNOWN_KEY, full stop.
Why Git Needs This
Git commits are content-addressed but not authenticated by default. Signing closes a specific set of gaps:
| Threat | Without signing | With signing |
|---|---|---|
| Email spoofing in a commit | Trivial — edit user.email, commit as anyone |
Signature fails under a spoofed identity |
| Force-push history rewrite | Undetectable after the fact | Old signed commits prove the rewrite happened |
| Compromised contributor account pushing as them | Indistinguishable from a legitimate push | Push is signed with the attacker's own key, or not signed at all — they can't forge your signature |
| Supply-chain insert in merged code | Relies on review process alone | Provenance trail: every commit attributable |
| Confused deputy in automation | Bots commit as a generic identity | Bot signs with its own key, attributable |
The incident that made this mainstream for a lot of people was the 2018 npm event-stream compromise and the typosquatter attacks that followed — malicious code merged under identities nobody had verified. Signed commits don't prevent that outright, but they give auditors a starting point.
The Shared Workflow
Both platforms follow the same skeleton:
- Generate a key — use an email that's a verified address on the platform (or the platform's no-reply address for privacy).
- Export the public key — the ASCII-armored block between
-----BEGIN PGP PUBLIC KEY BLOCK-----and-----END.... - Upload it to your account — GitHub: Settings → SSH and GPG keys → New GPG key. GitLab: Avatar → Preferences → GPG Keys.
- Configure git:
git config --global user.signingkey <KEYID>
git config --global commit.gpgsign true # sign everything by default
git config --global tag.gpgSign true
- Sign and push —
git commit -S -m "..."(or letcommit.gpgsignhandle it), thengit pushand check the badge.
Local verification works everywhere, no platform required:
git log --show-signature
git verify-commit HEAD
GitHub Behavior
- Verification engine: OpenPGP libraries checked against keys uploaded to your account.
- Badge states, by default: a signed, successfully verified commit shows Verified; anything that fails verification shows Unverified; anything else shows no badge at all. If you turn on vigilant mode (Settings → SSH and GPG keys → Vigilant mode), GitHub starts flagging every unsigned commit as Unverified too, and introduces a third state: Partially verified, which only appears when the commit's author is someone other than the committer and that author also has vigilant mode enabled — it means the signature proves who committed, but not that the listed author consented. Most repos never see this badge unless they've opted in.
- Email rule: the committer email must match a verified email associated with the GPG key. Unverified email = no badge.
- Multiple keys are allowed. Expired and revoked keys can stay uploaded — historical commits keep their prior status, with the expiry or revocation noted in the badge detail.
- Web editor commits: GitHub signs these automatically with its own
web-flowkey (https://github.com/web-flow.gpg). The badge means "GitHub made this commit on behalf of the user," not "the user's key signed it." - Enforcement: organization and branch protection rules can require signed commits before merge.
- S/MIME is available for corporate environments where keys come from an internal CA.
Key lifecycle on GitHub: removing a key leaves past commits verified; commits signed after removal show unverified. If you want to preserve historical verification for an old key, you can leave an expired or revoked key uploaded rather than deleting it.
GitLab Behavior
Same core flow, different operational details:
- Its own keyring — no public keyserver access.
- Supports GPG, SSH, and X.509 signing.
- Explicit failure codes, visible via the Commits API:
| Code | Meaning | Fix |
|---|---|---|
UNKNOWN_KEY |
Key not on the account | Upload the public key |
UNVERIFIED_KEY |
Key is on the account, but no verified email on the key matches the committer | Verify the email, or fix the committer email |
SAME_USER_DIFFERENT_EMAIL |
Key is valid, but doesn't contain the committer email — it has a different valid email for that person | Amend the commit email, or add the email to the key |
OTHER_USER |
Signature and key are valid, but the key belongs to someone other than the committer | Correct the committer, or use your own key |
UNVERIFIED |
The signature itself is invalid | Re-sign |
MULTIPLE_SIGNATURES |
More than one GPG/X.509 signature on the commit | Amend down to a single signature |
- Revoke vs. Remove — this distinction genuinely matters:
- Remove a key: past commits stay verified, future ones go unverified.
- Revoke a key: past and future commits flip to unverified. Use this when the key is compromised.
- Enforcement: the push rule "Reject unsigned commits," settable at project or group level, refuses the push outright — stronger than a badge-only workflow.
- Web UI commits: signed with an instance-wide key via Gitaly (
gitaly_gpg_signing, enabled by default on self-managed and GitLab Dedicated since GitLab 17.0, and rolled out to GitLab.com in later releases). Instance admins configure the signing key; mirrored repos carry the signature with them.
GitHub vs. GitLab at a Glance
| Aspect | GitHub | GitLab |
|---|---|---|
| Signing methods | GPG, SSH, S/MIME | GPG, SSH, X.509 |
| Key source | Account keyring only | Account keyring only |
| Badge granularity | Verified / Partially verified (vigilant mode only) / Unverified | Verified / Unverified + API status codes |
| Remove key | Past stays verified | Past stays verified |
| Revoke key | Past stays verified, noted in detail | Past flips to unverified |
| Enforcement | Branch protection / org rules | Push rules (reject at push time) |
| Web commits | web-flow GPG key |
Instance key via Gitaly |
| Local verify | git log --show-signature |
Same + Commits API |
Who Actually Needs This
Ranked by how much signing matters to them:
Must have:
- Open-source maintainers — the review flow accepts patches from strangers; signed commits are the first filter when auditing who touched released code.
- Release engineers — package provenance: npm, PyPI, and distro packaging communities increasingly expect signed contributor history or signed tags.
- Regulated environments (finance, healthcare, defense) — the audit trail requirement "every change attributable to an authenticated individual" maps directly to signed commits plus SSO.
- Security teams enforcing supply-chain policy — the Sigstore/cosign world made provenance fashionable; signed git history is the upstream end of the same chain.
- Teams with shared or bot accounts — bot commits signed with a bot key keep them separate from humans' keys.
Should have:
- Any team under branch protection already — cheap to set up once, and it blocks the "someone pushed as the CTO" class of incident.
- Maintainers of widely-forked repos — downstream distributors can verify they got your actual history, not a mirrored-and-modified copy.
- Consultants and contractors — proves which changes were theirs in a dispute.
Can skip:
- Solo projects and hobby repos — no adversary, no audience for verification. SSH access control alone covers it.
- Teams already using SSH signing — both platforms accept SSH signatures. If you already have Ed25519 keys from your SSH workflow, signing works without ever touching GPG.
- Pure internal, low-risk repos — commit access is already gated by SSO, so the marginal benefit is small (still nice, just not critical).
GPG or SSH Signing?
Both platforms support both. Here's the pragmatic breakdown:
| Concern | Winner | Why |
|---|---|---|
| Setup effort | SSH | Reuse an existing SSH key, one config line (gpg.format ssh) |
| Key expiry/revocation UX | GPG | Native lifecycle, badges reflect expiry |
| Subkey hierarchies / offline primary key | GPG | SSH has no subkey concept |
| Algorithm flexibility | Tie | Ed25519 on both sides |
| Already have SSH keys | SSH | Zero new key material |
| Compliance wanting "GPG" by name | GPG | Some policies specify OpenPGP explicitly |
If the SSH route interests you, it's three lines:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
Upload the same public key to the platform as a signing key. GitHub treats SSH keys with signing capability natively; GitLab wants the key's usage type set to Authentication & Signing or Signing.
Setup Reference
The full path in five commands (GPG flavor):
# 1. Generate (use a verified email or the platform's no-reply address)
gpg --full-generate-key # ECC, Ed25519, no expiry or 2y expiry
# 2. Get the key ID
gpg --list-secret-keys --keyid-format=long
# 3. Export the public key
gpg --armor --export <KEYID> # copy the BEGIN/END block → paste into the platform
# 4. Point git at it
git config --global user.signingkey <KEYID>
git config --global commit.gpgsign true
# 5. Sign
git commit -S -m "signed commit"
If the passphrase prompt on every sign gets old, set up pinentry integration (GPG Suite on macOS, Gpg4win on Windows) or configure gpg-agent caching so you're not typing it a dozen times a day.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Commit shows Unverified, signature valid | Email mismatch | user.email must equal a verified email on the key |
UNKNOWN_KEY (GitLab) / no badge despite an uploaded key |
Wrong key uploaded, or committer email isn't on the key | Re-export the correct key; confirm it contains the committer email (gpg --list-keys) |
| Badge missing for web-made commits | Web commits are signed with the platform's key, not yours | Expected — verify against the published platform key if you need to confirm it |
| GitHub can't verify after adding a key | Email on the key was never verified on GitHub | Verify the email in GitHub settings |
| Old commits unverified after key cleanup | Key was removed or revoked | Re-upload the key (GitHub keeps history verified for a removed key) |
| Every sign prompts for a passphrase | gpg-agent isn't caching |
Configure gpg-agent's default-cache-ttl |
gpg: signing failed: secret key not available |
Wrong user.signingkey, or the key lives on a different machine |
Check gpg --list-secret-keys, fix the config |
| Partially verified badge (GitHub) | Vigilant mode is on and the commit's author differs from its committer | Expected behavior — it means the author's consent isn't proven, not that something's broken |
Bottom Line
PGP on GitHub and GitLab is the same asymmetric crypto SSH keys use for access — except the product here is attributable history instead of push permission. Generate a key pair, upload the public half, sign commits with the private half, and the platform matches the signature against its own keyring. No web of trust, no keyservers, nothing exotic.
Who needs it: anyone whose commits get reviewed, audited, released, or distrusted — maintainers, release engineers, regulated shops, teams blocking shared-account pushes. Who doesn't: solo repos where SSH access control already tells the whole story.
And there's a shortcut if GPG feels like overkill: the SSH keys you already set up from the SSH post can sign commits too. One less key to manage, same Verified badge.
Sources: GitHub Docs — About commit signature verification, GitHub Docs — Displaying verification statuses for all of your commits, GitLab Docs — Signed commits, GitLab Docs — Sign commits with SSH keys, GitLab Docs — Signed commits from the GitLab UI.