Verifying commit signatures in a pre-receive hook Jump to heading
Hosted forges give you a “require signed commits” switch, but a self-hosted server — Gitolite, a bare repository behind SSH, Gitea with custom hooks, or GitLab with server hooks — has nothing but the hook interface. A pre-receive hook is the one place where the server can refuse a push before any ref moves, which makes it the strongest gate available: no client setting, --no-verify or forgotten local configuration can get around it. The difficulty is doing it correctly. A naive hook checks only the tip commit, or every commit in the repository, or silently trusts any key it can parse. This recipe builds the version that checks exactly the commits a push introduces, against an explicit list of trusted keys, and is part of the wider set of commit verification gates.
When to use this approach Jump to heading
- You run your own Git server and cannot rely on a forge’s branch-protection setting to enforce signing.
- Your forge offers a signing rule, but it only checks that a signature exists — you need it to belong to a known contributor, which the allowed signers file built from team membership provides.
- Contributors already sign with SSH or GPG keys, set up as described in GPG vs SSH commit signing.
- You want the same policy enforced for direct pushes, mirror pushes and pushes from automation, not just for merges made through a web interface.
- If your forge can run a CI job before merge and you never accept direct pushes, a range check in CI may be simpler to operate than a server hook.
Step 1 — Read the ref updates from standard input Jump to heading
A pre-receive hook receives one line per ref being updated: the old object ID, the new object ID and the ref name. Every decision starts from those three values, so the hook’s first job is to parse them without assumptions.
#!/bin/sh
# hooks/pre-receive — runs once per push, before any ref is updated
set -eu
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0') # all-zero OID, any hash length
while read -r old new ref; do
case "$ref" in
refs/heads/*) ;; # branches are checked
refs/tags/*) ;; # tags too — see the FAQ for signed tags
*) continue ;; # notes, internal refs: out of scope
esac
[ "$new" = "$zero" ] && continue # branch deletion: nothing to verify
printf '%s %s %s\n' "$old" "$new" "$ref"
done The all-zero object ID marks creation (when it is old) and deletion (when it is new). Computing it from git hash-object rather than hard-coding forty zeros keeps the hook correct on SHA-256 repositories.
# Verification: push to a scratch server repo and watch the parsed lines
git push scratch HEAD:refs/heads/hook-test 2>&1 | grep 'remote:' What changed: nothing is enforced yet, but you now know exactly which updates the hook sees, including the deletion case it must skip.
Step 2 — List only the commits this push introduces Jump to heading
Checking old..new is wrong for new branches, where old is zero, and it rechecks commits the server already has when a branch is reset to an older history. The robust form asks for commits reachable from the new tip that are not reachable from any existing ref.
new_commits() {
# $1 = new tip. --not --all excludes everything the server already has.
git rev-list "$1" --not --all
} Because the hook runs before refs are updated, --all still describes the repository as it was, so the result is exactly the set of objects the push brought in. A branch created from an already-signed commit yields an empty list, which is correct: those commits were verified when they first arrived.
# Verification: a push of three new commits reports three, a re-push reports zero
git rev-list "$(git rev-parse HEAD)" --not --all | wc -l Step 3 — Configure the server to verify against a trusted list Jump to heading
For SSH signatures, Git verifies against an allowed_signers file. For GPG it uses the server user’s keyring. Either way, the trust store lives on the server, owned by the account that runs hooks, and not writable by anyone who can push.
# On the server, as the git user
git config --global gpg.ssh.allowedSignersFile /etc/git/allowed_signers
# Optional: enforce SSH signatures only and ignore GPG entirely
git config --global gpg.format ssh
# The file maps principals (emails) to public keys, one per line:
# [email protected] namespaces="git" ssh-ed25519 AAAAC3Nz...
ls -l /etc/git/allowed_signers # owner root or git, mode 0644, not writable by pushers The namespaces="git" option restricts each key to Git signatures, so a key that also signs files for another purpose cannot be replayed here. Keeping the file outside the repository matters: if it were read from the pushed tree, a pusher could add their own key in the same push that relies on it.
# Verification: an existing signed commit verifies on the server
git -C /srv/git/app.git verify-commit "$(git -C /srv/git/app.git rev-parse main)" && echo trusted Step 4 — Verify each commit and check the signer matches the committer Jump to heading
git verify-commit exits non-zero for missing or bad signatures, and for SSH signatures from keys not in the allowed signers file. That alone is not quite enough: a contributor with a valid key could sign a commit whose committer email names someone else. The format placeholders let the hook compare the two.
check_commit() {
c=$1
status=$(git log -1 --format='%G?' "$c")
case "$status" in
G) ;; # good signature from a trusted key
*) echo "$c: signature status $status"; return 1 ;;
esac
signer=$(git log -1 --format='%GS' "$c") # principal from allowed_signers
committer=$(git log -1 --format='%ce' "$c")
if [ "$signer" != "$committer" ]; then
echo "$c: signed by $signer but committed as $committer"
return 1
fi
} Status G is the only acceptable one. U means good signature but unknown validity, which for GPG usually means an untrusted key; N is unsigned; B, E, X, Y and R cover bad signatures, missing keys and expired or revoked keys. Treating everything except G as a failure is deliberately strict.
# Verification: an unsigned commit is named in the output
git commit --allow-empty -m "unsigned probe" --no-gpg-sign
git push scratch HEAD:refs/heads/hook-test 2>&1 | grep -E 'remote: .*status N' Step 5 — Assemble the hook and fail with a useful message Jump to heading
The final hook collects every failure before exiting, so a contributor sees all the commits that need re-signing in one push attempt rather than discovering them one at a time.
#!/bin/sh
set -eu
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')
failures=$(mktemp); trap 'rm -f "$failures"' EXIT
while read -r old new ref; do
[ "$new" = "$zero" ] && continue
case "$ref" in refs/heads/*|refs/tags/*) ;; *) continue ;; esac
for c in $(git rev-list "$new" --not --all); do
check_commit "$c" >>"$failures" || true
done
done
if [ -s "$failures" ]; then
echo "Push rejected: every new commit must be signed by a trusted key."
sed 's/^/ /' "$failures"
echo "Re-sign with: git rebase --exec 'git commit --amend --no-edit -S' <base>"
exit 1
fi Paste the check_commit function above the loop. Install the file as hooks/pre-receive in the bare repository, mark it executable, and make sure the hooks directory is owned by the server account so it cannot be replaced through a push.
# Verification: install, then confirm both outcomes
install -m 0755 pre-receive /srv/git/app.git/hooks/pre-receive
git push scratch HEAD:refs/heads/hook-test # expect rejection for the unsigned probe
git commit --amend --no-edit -S && git push scratch HEAD:refs/heads/hook-test # expect success ⚠️ SAFETY WARNING: The re-signing command in the rejection message rewrites the contributor’s local commits. That is safe for commits that were never accepted by the server — which is exactly the set this hook rejects — but contributors should not run it over commits that already exist on a shared branch. If someone does,
git reflogon their machine recovers the original commits:git reset --hard HEAD@{1}.
Step 6 — Handle the commits the server creates itself Jump to heading
Merges made through a web interface, and commits made by bots, arrive through the same hook. They need their own trusted identities, otherwise the gate blocks the tools you rely on. Add the forge’s merge-signing key and each bot’s key to the allowed signers file with distinct principals, and keep a short comment explaining each.
# /etc/git/allowed_signers — service identities, kept at the end of the file
# web merges (key rotated 2026-06)
[email protected] namespaces="git" ssh-ed25519 AAAAC3Nz...
# dependency bot
[email protected] namespaces="git" ssh-ed25519 AAAAC3Nz... How forge merges get signed in the first place is covered in verifying signatures on merge commits created by the forge, and signing bot commits is in signing bot commits made by CI workflows.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does the hook slow down large pushes? Jump to heading
Only in proportion to the number of new commits, because --not --all excludes everything the server already holds. Verifying an SSH signature takes milliseconds, so even an import of a few thousand commits finishes in seconds. A full-history migration is the exception; push it once with the hook temporarily set to warn, then enable enforcement.
Should the same hook verify signed tags? Jump to heading
Yes, but with git verify-tag rather than verify-commit, and only for annotated tags — a lightweight tag has no object of its own to sign. Check git cat-file -t "$new" and branch on tag versus commit so each ref type gets the right verification.
What about commits that were signed with a key that has since been rotated? Jump to heading
They were verified when they arrived and are never checked again, because --not --all excludes them. That is the point of verifying at the gate: a rotation only needs to affect new pushes. Keep old keys in the allowed signers file with a valid-before option if you also run historical audits.
Related Jump to heading
- Commit Verification Gates — the parent topic, covering every place a signature can be enforced.
- Rolling Out Signature Enforcement in Warn-Only Mode — how to switch this hook on without locking anyone out.
- Building an Allowed Signers File from Team Membership — generating the trust store this hook reads.
- Mirroring Local Hook Checks in Server-Side Policy — keeping client and server rules in step.