Commit Signing & Git Supply-Chain Security: Provenance You Can Enforce Jump to heading
The author name on a Git commit is a free-text field. Anyone can set user.name and user.email to any string and produce a commit that claims to be from your staff engineer, your release bot, or a maintainer who left the project two years ago. Git will happily record it, git log will happily display it, and your platform’s contributor graph will happily attribute it. Nothing about a plain commit ties the recorded identity to the person who actually created it.
That gap is a supply-chain problem, not a cosmetic one. The two failure shapes are forged author identity — a commit that falsely claims to come from a trusted contributor — and unsigned commit injection — a commit slipped into protected history with no cryptographic provenance at all. Both let an attacker who obtains write access, or who compromises a single account, blend malicious changes into a trusted-looking history. Downstream builds, deployments, and audits then treat that history as authoritative.
Commit signing closes the gap, but only when it is enforced end to end. Signing binds a commit object to the holder of a private key. Verification, run on a server you control, decides whether that key is one you trust for that identity. This page maps the full trust chain — from the developer’s key through the signed object, CI verification, the branch-protection gate, and finally an immutable signed release tag — and connects to the deeper material on GPG vs SSH commit signing, verifying signed commits in CI pipelines, and commit verification gates. The recurring theme: a signature no server checks is worth nothing.
How the Trust Chain Fits Together Jump to heading
The diagram below traces provenance from left to right. A commit signed on a developer’s workstation is only trustworthy at the far end if every link in between is present. Break any link — omit the allowed-signers mapping, skip CI verification, leave the branch unprotected — and the chain provides no guarantee.
Core Concept: The Signature Is Over the Commit Object Jump to heading
A Git commit is a small, immutable object: it names a tree (the snapshot), zero or more parent commits, an author with a timestamp, a committer with a timestamp, and a message. Its identity — the SHA — is a hash of exactly those bytes. Signing does not attach a signature to a file or a diff; it produces a cryptographic signature over the serialized commit object and stores that signature inside the object as the gpgsig header. Because the signature is part of what the SHA covers, you cannot alter the tree, the parents, or the author line without invalidating the signature and changing the hash.
That is precisely why signing is meaningful. The parent pointers are inside the signed payload, so a signed commit vouches for its position in history, not just its content. An attacker cannot graft a signed commit onto a different parent chain and keep the signature valid. The same mechanics apply to annotated tags: a signed tag object contains the target object ID, the tag name, the tagger, and the message, and the signature covers all of it.
Create a signed commit and inspect what the signature actually covers:
# Make a signed commit explicitly (‑S forces signing for this commit)
git commit -S -m "feat: add token bucket rate limiter"
# Show the signature status and the signer alongside the commit
git log --show-signature -1
# Dump the raw object: the gpgsig header sits between committer and message
git cat-file -p HEAD The git cat-file -p HEAD output shows the gpgsig block embedded in the header region. Verification recomputes the object bytes with that block removed, then checks the signature against the recorded payload. If any byte of the tree, parent, or author fields is changed, verification fails.
SAFETY WARNING: Amending or rebasing a signed commit rewrites the object and drops the original signature unless you re-sign.
git rebase --exec 'git commit --amend --no-edit -S'orgit rebase -Sre-signs replayed commits; a plain rebase leaves them unsigned. Before force-pushing rewritten history, capture the old tip withgit reflogso you cangit reset --hard <old-sha>if signatures come out wrong.
Core Concept: GPG vs SSH Signing Formats Jump to heading
Git supports three signing backends selected by gpg.format: openpgp (the default, GPG), ssh, and x509 (S/MIME via gpgsm). For most teams the real choice is between the first two, covered in depth in GPG vs SSH commit signing. Both produce a signature over the commit object and both are equally strong cryptographically; they differ entirely in key management.
GPG signing uses OpenPGP keys held in a GPG keyring. It brings first-class support for key expiry, revocation certificates, subkeys, and the ability to keep the private key on a hardware token such as a YubiKey — see signing Git commits with a YubiKey. The cost is operational: keyrings, agent configuration, expiry management, and the perennial gpg: signing failed: Inappropriate ioctl for device that appears when the pinentry cannot reach a TTY.
SSH signing, added in Git 2.34, reuses the SSH keys developers already hold. There is no keyring and no web of trust; a public key plus an allowed-signers file is the entire trust model. This is why many teams are migrating from GPG to SSH commit signing. The trade-off is that SSH signing has no native expiry or revocation protocol — you express validity windows and revocation through the allowed-signers file and a separate revoked-keys list.
Configure SSH signing for a single developer:
# Select the SSH backend and point at the public key used to sign
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
# Prove signing works before relying on it
git commit -S --allow-empty -m "chore: verify signing setup"
git log --show-signature -1 Configure GPG signing instead, when you need OpenPGP features:
# List secret keys and copy the long key id after the algorithm slash
gpg --list-secret-keys --keyid-format=long
# Point Git at that key and enable OpenPGP signing
git config --global gpg.format openpgp
git config --global user.signingkey 3AA5C34371567BD2
git config --global commit.gpgsign true
# Non-interactive/headless environments need a TTY hint for pinentry
export GPG_TTY=$(tty) The git log --show-signature -1 on your own machine may print Good "git" signature while a teammate sees No principal matched. That difference is not a bug — it is the allowed-signers model doing its job, described next.
Core Concept: The Allowed-Signers Model and Trust Jump to heading
A valid signature and a trusted signature are different claims. “Valid” means the bytes were signed by the private key matching some public key. “Trusted” means that public key is one you have decided is authorized to sign as a particular identity. Git separates these deliberately. Verification can succeed at the cryptographic level and still report the commit as unverified because there is no basis to trust the signer.
For SSH signing, the trust anchor is the allowed-signers file. Each line binds an identity — normally the committer email — to one or more public keys, optionally with validity windows. Git reads it through gpg.ssh.allowedSignersFile. Without it, git verify-commit can confirm a signature is valid but has no principal to attribute it to, and reports No principal matched.
Build an allowed-signers file and wire it up:
# One identity per key; namespaces and validity windows are optional
# format: <principal> [options] <key-type> <public-key>
cat > .config/git/allowed_signers <<'EOF'
[email protected] namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
[email protected] namespaces="git",valid-after="20260101" ssh-ed25519 AAAAC3Nz...
EOF
# Tell Git where the trust anchor lives
git config --global gpg.ssh.allowedSignersFile "$HOME/.config/git/allowed_signers"
# Now verification attributes the signer to a principal
git verify-commit HEAD For GPG, the analogous trust decision is whether the signer’s key is present in the verifier’s keyring with sufficient trust. In both models the same principle holds: the list of authorized keys is the security boundary, and it must be managed as carefully as any access-control list. Compromise or careless edits to this file undermine every downstream check, which is why key custody is treated separately in protecting and rotating signing keys.
SAFETY WARNING: The allowed-signers file is a trust root. Anyone who can add a line can authorize a key to impersonate any identity. Store it in a protected location, require review on changes (a CODEOWNERS entry works well), and never let CI write to the copy it verifies against. If a key is compromised, move it to a revoked-keys list immediately rather than merely deleting the line — deletion silently un-verifies its past commits, which may not be what you want.
Core Concept: Signing Is Worthless Without Server-Side Verification Jump to heading
Everything so far happens on the developer’s machine. The uncomfortable truth is that client-side signing, on its own, secures nothing. The signature is produced locally and pushed with the commit, but nothing on the receiving end is obligated to look at it. An attacker with write access — or anyone who has compromised a contributor’s account — can push commits that are unsigned, or signed by a key that is valid but not trusted, and an unguarded remote accepts them without complaint. The green “Verified” badge some contributors see is a per-platform courtesy, not an enforcement mechanism.
Verification is what converts a signature into a guarantee, and it has to run somewhere the attacker does not control: in CI and at the branch-protection layer. The verifying signed commits in CI pipelines cluster covers this in depth, including verifying signed commits in GitHub Actions and the keyless approach in keyless commit signing with Sigstore gitsign.
A correct CI check verifies every commit introduced by a change, not just the tip. A range can contain an unsigned parent hidden behind a signed head commit:
#!/usr/bin/env bash
# ci/verify-signatures.sh — fail if any commit in the range is untrusted
set -euo pipefail
# BASE..HEAD is the set of commits this change introduces
BASE="${1:-origin/main}"
export GIT_CONFIG_GLOBAL=/dev/null # ignore developer config in CI
git config --global gpg.format ssh
git config --global gpg.ssh.allowedSignersFile "$PWD/.config/git/allowed_signers"
fail=0
for sha in $(git rev-list "${BASE}..HEAD"); do
if git verify-commit "$sha" 2>/dev/null; then
echo "ok $sha"
else
echo "FAIL $sha is not signed by a trusted key"
fail=1
fi
done
exit "$fail" # Verification: run the gate locally against your default branch first
bash ci/verify-signatures.sh origin/main && echo "all commits trusted" The final enforcement layer is commit verification gates at the platform. Enforcing signed commits with branch protection makes the remote itself reject any push to a protected branch that contains an unverified commit, closing the loop that CI alone leaves open. This complements the broader branch-protection posture in the trunk-based branch protection model and the local guardrails in pre-push validation rules, which can warn a developer before a rejected push wastes a round trip.
SAFETY WARNING: Requiring signed commits on a busy branch without first onboarding every contributor’s key will block all merges the moment it is enabled. Verify that every active committer’s key is in the allowed-signers file and uploaded to the platform, run the CI gate in report-only mode for a sprint, and only then flip branch protection to enforce. Keep a documented break-glass path (a repo admin can temporarily lift protection) for the inevitable key emergency.
Configuration Reference Jump to heading
The keys below govern signing and verification behaviour on Git v2.30+. SSH signing keys require Git 2.34 or later.
| Config Key | Default | Effect | When to Change |
|---|---|---|---|
commit.gpgsign | false | Signs every commit automatically without needing -S | true everywhere provenance is required; enforce via branch protection |
tag.gpgsign | false | Signs every annotated tag automatically | true so release tags carry provenance without a manual --sign |
gpg.format | openpgp | Selects the signing backend: openpgp, ssh, or x509 | ssh for simpler key management; openpgp when you need expiry/revocation |
user.signingkey | unset | The key used to sign: a GPG key id, or a path/literal for an SSH public key | Always set; without it signing fails or picks the wrong key |
gpg.ssh.allowedSignersFile | unset | Path to the identity-to-key trust map used to verify SSH signatures | Set on every verifier (developer and CI) or verification reports no principal |
gpg.ssh.program | ssh-keygen | The helper invoked to create and check SSH signatures | Point at a specific ssh-keygen or a smartcard-aware wrapper for hardware keys |
push.gpgSign | false | Signs the push certificate so the remote can verify who pushed | if-asked or true where the remote validates signed pushes for an audit trail |
merge.verifySignatures | false | Refuses to merge a commit that does not have a trusted signature | true on integration branches to block untrusted merges locally |
Two keys are frequently confused. commit.gpgsign signs the commit object itself and is what CI and branch protection verify. push.gpgSign signs a separate push certificate covering the ref update — it proves who performed a push, which is a different guarantee useful for a server-side audit log, and it requires the remote to support and record signed pushes.
Team Rollout Checklist Jump to heading
Turning on enforcement is the easy part; sequencing it so the team is not locked out is the work. Enable signing capability first, verify in report-only mode, and gate last.
Common Failure Modes & Diagnostics Jump to heading
1. Signature Valid but Reported as Unverified Jump to heading
Symptom: git verify-commit HEAD prints Good "git" signature on the author’s machine but No principal matched (SSH) or Can't check signature: No public key (GPG) elsewhere, and the platform shows the commit as Unverified.
Root cause: The signature is cryptographically valid but the verifier has no trust binding for the signer — the SSH allowed-signers file is missing the identity, or the GPG public key is absent from the verifier’s keyring.
Fix:
# SSH: confirm the verifier is actually reading an allowed-signers file
git config --get gpg.ssh.allowedSignersFile
# Add the missing identity-to-key line, then re-verify
git verify-commit HEAD
# GPG: import the signer's public key into the verifying keyring
gpg --import contributor-public-key.asc Ensure CI and every developer point at the same authoritative allowed-signers file rather than divergent local copies.
2. Rebase Silently Strips Signatures Jump to heading
Symptom: Commits that were signed lose their signatures after an interactive rebase or amend; branch protection then rejects the push with “commits must be signed”.
Root cause: Rewriting a commit produces a new object, and a plain rebase or --amend does not re-sign it. The signature covered the old bytes and is discarded with them.
Fix:
# Re-sign every commit replayed in a rebase
git rebase -S origin/main
# Re-sign a single amended commit
git commit --amend --no-edit -S
# Confirm the whole range is signed again
git log --show-signature origin/main..HEAD | grep -c "Good" Set commit.gpgsign true globally so signing is the default even during rebases performed by scripts.
3. GPG Signing Fails in Headless or CI Environments Jump to heading
Symptom: error: gpg failed to sign the data and gpg: signing failed: Inappropriate ioctl for device when signing on a server, in a container, or over SSH.
Root cause: GPG’s pinentry cannot find a terminal to prompt for the passphrase because there is no attached TTY.
Fix:
# Tell GPG which TTY to use for pinentry
export GPG_TTY=$(tty)
# For fully non-interactive agents, allow loopback pinentry
echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent
# Verify signing now succeeds
git commit -S --allow-empty -m "chore: ci signing smoke test" For CI specifically, prefer a dedicated bot key with no passphrase stored in the secrets manager, or switch that pipeline to SSH signing, which sidesteps pinentry entirely.
4. Unsigned Commit Slips Past a Tip-Only Check Jump to heading
Symptom: An audit finds an unsigned commit in the middle of a merged range even though CI reported the change as verified.
Root cause: The verification job checked only HEAD (the tip) instead of every commit in BASE..HEAD. A signed merge or head commit can sit atop unsigned parents.
Fix:
# Wrong: verifies only the tip
git verify-commit HEAD
# Right: verify every commit the change introduces
for sha in $(git rev-list origin/main..HEAD); do
git verify-commit "$sha" || { echo "unsigned: $sha"; exit 1; }
done Enforce the same range check at the platform by requiring signed commits in branch protection, which evaluates the full pushed range rather than just the tip.
5. Release Tag Verifies Locally but Not for Consumers Jump to heading
Symptom: git tag -v v4.2.0 succeeds for the release engineer, but downstream consumers running git verify-tag v4.2.0 see it as untrusted or fail their supply-chain check.
Root cause: The tagger’s public key is not distributed to consumers, or the tag was a lightweight tag (a bare ref with no object to sign) rather than a signed annotated tag.
Fix:
# Confirm it is an annotated, signed tag object — not a lightweight ref
git cat-file -t v4.2.0 # must print "tag", not "commit"
# Create a signed annotated release tag
git tag -s v4.2.0 -m "Release v4.2.0"
# Consumers verify against a distributed trust anchor
git verify-tag v4.2.0 Publish the release-signing public key alongside the artifacts (in the repo’s allowed-signers file or a documented keyserver) so consumers have a trust anchor. Treat the tag as write-once — see the immutability guidance in release tagging and versioning.
SAFETY WARNING: Re-signing or moving an already-published release tag breaks any consumer that pinned the previous object. Never
git tag -fa released version; cut a new patch version instead. Enforce tag protection at the platform level to block force-push and deletion of release tags.
Frequently Asked Questions Jump to heading
Does a signed commit prove who wrote the code? Jump to heading
No. A signature proves that the holder of a specific private key produced the commit object as it exists, including its author and committer fields. It does not prove the person named in the author field wrote the code, and it does not stop someone with a valid key from signing a commit that attributes authorship to a colleague. Provenance comes from binding keys to identities in an allowed-signers file and verifying that binding server-side, so a signature can be traced back to an authorized key rather than a free-text name.
Should we use GPG or SSH signing? Jump to heading
SSH signing is simpler for most teams because developers already have SSH keys and no keyring, expiry, or web-of-trust machinery is required. GPG is the right choice when you need key expiry, revocation certificates, subkeys on hardware tokens, or interoperability with existing OpenPGP infrastructure. Both produce a signature over the commit object and are equally strong; the difference is key management, not security strength. Teams frequently start on GPG and migrate to SSH once they find the operational overhead outweighs the features they actually use.
Is client-side signing enough on its own? Jump to heading
No. Signing on the developer’s machine only produces the signature. Without server-side verification a signed history is decorative: an attacker can push unsigned commits, or commits signed by an untrusted key, and nothing rejects them. The value of signing is realized only when a verification gate — in CI and at the branch-protection layer — refuses unsigned or untrusted commits before they land on a protected branch. Treat client signing as a prerequisite for verification, never as the security control itself.
What is an allowed-signers file and why does it matter? Jump to heading
For SSH signing, the allowed-signers file maps an identity (typically an email) to one or more public keys trusted to sign as that identity, optionally with validity windows. Git and ssh-keygen consult it to decide whether a signature is not just valid but trusted. Without it, Git can confirm a signature is cryptographically valid while having no basis to say the signer is authorized, so verification reports the commit as unverified. It is the trust root of the whole system and must be managed with the same care as an access-control list.
How do we verify signatures in CI? Jump to heading
Provision the allowed-signers file (or import the trusted GPG keys) into the CI environment, set gpg.ssh.allowedSignersFile, then iterate the commits introduced by the change and run git verify-commit on each, failing the job on the first commit not signed by a trusted key. Verifying only the tip commit is insufficient because a range can contain injected unsigned parents. Run the job in report-only mode first to catch contributors whose keys are missing from the trust anchor before you make it blocking.
What happens to old commits when a signing key is rotated? Jump to heading
Rotating a key does not invalidate existing history unless you deliberately distrust the old key. Keep the retired public key in the allowed-signers file with a valid-before date so commits made while it was current still verify, and add the new key with a valid-after date so the transition is unambiguous. Only remove or explicitly revoke the old key when you believe it was compromised and want past signatures to stop being trusted — the full procedure is covered in the key rotation runbook.
Related Jump to heading
- GPG vs SSH Commit Signing — a side-by-side comparison of the two signing backends with key management, hardware token, and migration trade-offs mapped out.
- Verifying Signed Commits in CI Pipelines — how to provision trust anchors into CI and reject unsigned or untrusted commits before an artifact is promoted.
- Commit Verification Gates — enforcing signed commits at the platform so the remote itself refuses unverified pushes to protected branches.
- Protecting & Rotating Signing Keys — custody, storage, and rotation of signing keys, including recovering trust after a suspected key compromise.
- Git Workflow Architecture & Branching Strategies — the branch-protection and trunk-based foundation that signed-commit enforcement plugs into.
- Pre-Push Validation Rules — local hooks that catch unsigned commits before a push is rejected by server-side verification.