Securing Your History with Signed Commits
Make every commit cryptographically attributable. SSH and GPG signing, allowed_signers, signed tags, host-side enforcement, and an honest account of what signing does not prove.
Make every commit cryptographically attributable. SSH and GPG signing, allowed_signers, signed tags, host-side enforcement, and an honest account of what signing does not prove.
You have spent this course taking Git apart — objects, bisect, history rewrites, merge drivers, worktrees, scale. Every one of those lessons assumed the history in front of you is the history someone actually wrote. Git does nothing by default to earn it.
By the end of this lesson every commit you push carries a cryptographic signature tied to a key only you hold, your repository rejects anything that does not, and you can say what that guarantee covers and where it stops.
A commit's author and committer fields are plain strings. Git copies them out of your config into the object without checking anything. Three commands is the whole demonstration:
git config user.name "Ada Lovelace" # free text
git config user.email "ada@example.com" # also free text
git commit --allow-empty -m "Looks official"That commit is now, as far as every tool is concerned, authored by Ada, and a host will render her name beside the avatar of whichever account owns that email. Nothing is broken: the fields were never a claim of identity. They are a label, like the return address on an envelope, and signing turns that label into evidence.
Recall from the object-model lesson that a commit object is a
short block of text: a tree hash, parents, author and committer
lines, a blank line, the message. A signature is a detached
signature over exactly those bytes, folded back into the object
as a gpgsig header.
git commit -S -m "Add rate limiter"
git cat-file -p HEADtree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
parent a1c3f9e0b7d2e6f81a4c5b9d0e3f7a2c6b8d1e40
author Maya Iqbal <maya@example.com> 1753500000 +0000
committer Maya Iqbal <maya@example.com> 1753500000 +0000
gpgsig -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvJ9k2Qp...
-----END SSH SIGNATURE-----
Add rate limiterThe signature lives inside the commit, on continuation lines indented by one space. The header is therefore part of the object, so signing changes the bytes and the hash changes with them: a signed commit and its unsigned twin are different commits. Verification reverses the trick — pull the header out, rebuild the commit text without it, check the signature against that. One changed byte and it fails.
Since Git 2.34 you can sign with an SSH key instead of a GPG key. For most developers that is the right default: the key is already on your machine, already on your hosting account, and already unlocked by your agent.
Note the .pub — with SSH signing, user.signingkey points at
the public half; Git finds the private key through your agent
or the matching file. The gpgsign settings mean you never type
-S again. That is the easy half, and where most write-ups
stop: on its own it makes signatures nobody can check yet.
A signature is only meaningful against a list of keys you have decided to believe. For SSH signing that is an allowed signers file, and without one, local verification cannot succeed no matter how correctly you signed.
Each line is a principal, optional options, then the key. Keys are truncated here; real lines are unwrapped:
The principal is matched against the committer's email, and
namespaces="git" scopes the key to Git signing, so a key you
also use elsewhere cannot be replayed into your history. Now
verification works:
For machines, --show-signature is unwieldy. The %G?
placeholder gives one status letter per commit:
For a hard pass or fail, prefer exit codes: git verify-commit
and git verify-tag return non-zero on failure.
Some organisations standardise on GPG, often because the same key backs package or email signing. The flow is longer, not harder.
Use ed25519 instead if your tooling accepts it. The ID follows
the algorithm on the sec line, after the slash:
Give keys an expiry — a year or two is common. An expired key
stops being usable without invalidating what it already signed.
Generate a revocation certificate the day you create the key
(gpg --output revoke.asc --gen-revoke <id>) and publish it if
the key is compromised: that is how you say "stop trusting
anything new from this key".
If you adopt one practice from this lesson, adopt this one. A signed tag is a tag object carrying its own signature over the commit it points at, its name, and its message:
A release is where a repository turns into an artefact someone
downloads and runs, and a signed tag is one small statement
about it: this exact tree is what we shipped as 1.0.0, and a
named human said so. Consumers check one object instead of a
thousand commits, and a build system can gate on git tag -v
before publishing.
Signing is a claim; verification is the check. A repository that accepts unsigned commits gains nothing from everyone signing.
Three layers, in increasing order of strength. Your host
verifies signatures against keys registered to an account and
shows a Verified badge, which says only that the host was
satisfied. Branch protection rules can require signed commits on
main and refuse anything else; start here, because it is one
checkbox and it is server-side. Strongest is a pre-receive
hook on a self-hosted server, or a CI job on every pull request,
walking the incoming range:
This is where the intermediate hooks lesson's distinction
bites. A local pre-commit hook lives in a directory
contributors control and is skipped with --no-verify — a
convenience, not a control. Enforcement happens on a machine
the contributor does not own.
Be precise about the guarantee.
The holder of a key created these exact bytes.
Change one character of the message, the tree or a parent hash and the signature fails.
Anything at all about the code.
Not that it is correct, not that it was reviewed, not that the author's laptop was uncompromised that morning.
Malware with an unlocked signing key produces perfectly valid signatures.
There is also a mechanical wrinkle. Any operation that re-creates a commit produces a new object, and the old signature does not survive it: rebase, amend, cherry-pick, squash-merge. When a host squashes or rebases for you, it builds new commits and either signs them with its own web-flow key or leaves them unsigned. Merge commits the host creates are signed by the host, not you.
So in a repository requiring signed commits, "every commit is
signed" and "every commit is signed by a human contributor" are
different policies. Teams resolve it three ways: allow the
host's key in the trust list, use merge commits so contributor
commits arrive intact, or verify only the pull request branch
rather than everything on main. Pick one and write it down.
Signing is one control in a set, and the rest are cheaper.
Protected branches with required reviews mean no single key,
compromised or not, lands code alone. Prefer
git push --force-with-lease over --force: the lease checks
the remote is where you last saw it, so you overwrite your own
work rather than a colleague's.
Turn on your host's secret scanning and push protection so a leaked token is caught at push time, not at incident time. Pin dependencies — CI actions especially — by full commit hash rather than tag, because a tag is a movable pointer, as the tags lesson showed; a hash is the content.
Finally, git fsck --full checks that every object hashes to
its own name and that the graph has no broken links. Setting
transfer.fsckObjects, receive.fsckObjects and
fetch.fsckObjects to true applies those checks to data in
motion, rejecting malformed objects on push and fetch.
You can now attribute every commit you create to a key you hold, verify anyone else's, and enforce that where it actually binds. You can also explain what a Verified badge does and does not mean, which is rarer than it should be.
The next lesson, Git in CI/CD pipelines, makes this routine: shallow and partial clones in pipelines, signature and tag verification as a build gate, release automation driven by signed tags, and workflows that hold up at team size.
Before you move on, do the small thing. Turn on SSH signing,
write an allowed_signers file with your own key in it, and run
git log --show-signature until a G beside your commits looks
ordinary. Then tag a release with git tag -s and verify it.
The habit is worth more than the theory.
maya@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1
sam@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1G good signature
B bad signature
U good signature, unknown validity
X good signature that has expired
Y good signature made by an expired key
R good signature made by a revoked key
E signature could not be checked (missing key, for example)
N no signaturegit 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 truegit config --global gpg.ssh.allowedSignersFile \
~/.config/git/allowed_signersgit log --show-signature -3 # signature status inline with log
git verify-commit HEAD # exit 0 if the signature verifiesgit log --pretty=format:'%h %G? %an %s' -10gpg --full-generate-key --default-new-key-algo rsa4096
gpg --list-secret-keys --keyid-format=long # find the long IDgit config --global user.signingkey 3AA5C34371567BD2
git config --global gpg.program gpg2 # if you have several
gpg --armor --export 3AA5C34371567BD2 # public key to uploadgit tag -s v1.0.0 -m "Release 1.0.0"
git push origin v1.0.0
git tag -v v1.0.0 # verify the tag's signature# CI: every commit in the PR range must verify
for c in $(git rev-list origin/main..HEAD); do
git verify-commit "$c" || exit 1
done# SSH signing (Git 2.34+, the modern default)
git config --global gpg.format ssh # use SSH keys
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true # sign all commits
git config --global tag.gpgsign true # sign all tags
git config --global gpg.ssh.allowedSignersFile \
~/.config/git/allowed_signers # the trust list
# GPG signing
gpg --full-generate-key --default-new-key-algo rsa4096
gpg --list-secret-keys --keyid-format=long # find the key ID
git config --global user.signingkey <KEYID> # tell Git
gpg --armor --export <KEYID> # public key to upload
gpg --output revoke.asc --gen-revoke <KEYID> # revocation cert
git config --global gpg.program gpg2 # pick the binary
export GPG_TTY=$(tty) # fix signing errors
# Signing and inspecting
git commit -S -m "msg" # sign one commit
git tag -s v1.0.0 -m "Release 1.0.0" # signed tag
git cat-file -p HEAD # see the gpgsig header
git log --show-signature # signatures in the log
git log --pretty=format:'%h %G? %an %s' # one status letter
git verify-commit HEAD # exit 0 if it verifies
git verify-tag v1.0.0 # exit 0 if it verifies
# Integrity and safety
git fsck --full # object integrity
git config --global transfer.fsckObjects true # push and fetch
git config --global receive.fsckObjects true # on receive
git config --global fetch.fsckObjects true # on fetch
git push --force-with-lease # safer force push