Commit Verification Gates Jump to heading
A signature on a commit is only worth as much as the gate that refuses to accept an unsigned one. This guide sits within the broader Commit Signing & Git Supply-Chain Security practice and covers the enforcement side: how to build layered verification gates so that an unsigned or unverifiable commit is rejected at the earliest possible point — and, if it slips past one layer, is caught by the next. Signing keys and their tradeoffs are covered elsewhere; here the question is purely how you make “every commit on main is verified” a property the system guarantees rather than a convention the team hopes to follow.
The defence is deliberately redundant. A developer’s laptop, the hosting platform, a self-hosted mirror, and the merge queue are four independent trust boundaries, each with its own bypass path. A single gate — say, a branch-protection toggle — leaves the tag ref, the mirror, and the --no-verify escape hatch wide open. Defence in depth means the same rule is checked at every boundary a commit crosses.
Prerequisites Jump to heading
Before wiring the gates below, verify your environment meets these requirements:
The Layered Gate Model Jump to heading
A commit travels from a developer’s index through four trust boundaries before it becomes part of the protected history. Each boundary is an opportunity to reject an unsigned or unverifiable object. The diagram shows where an unsigned commit dies at each layer, and — critically — which bypass path routes around each gate so you know why the next layer exists.
Read the dashed lines as the reason each subsequent gate exists: a local hook is bypassed with --no-verify, so Gate 2 re-checks server-side; branch protection guards only named branches, so Gate 3 checks every ref on a self-hosted server; and the platform’s own merge commit can be unsigned, so Gate 4 verifies the final object. No single layer is sufficient, which is the whole point.
Step 1 — Reject Unsigned Commits Locally Jump to heading
Intent: Give the developer instant feedback so unsigned commits never leave the workstation. This is the cheapest gate to trip and the least authoritative — it exists for speed, not security.
Two local checkpoints matter. The pre-commit hook can refuse to create an unsigned commit, and the pre-push hook can refuse to send a range of commits that contains any unverified object. The pre-push guard is the stronger of the two because it validates the entire push range, including commits authored before the hook was installed. Distribute both through Husky so they travel with the repository rather than living in an un-cloned .git/hooks.
First, make signing automatic so developers are never prompted to opt in:
# Per-repo signing config (Git 2.30+); works for GPG or SSH keys
git config commit.gpgsign true # Sign every commit by default
git config gpg.format ssh # Omit this line if you sign with GPG
git config user.signingkey ~/.ssh/id_ed25519.pub Then add a Husky pre-push guard that walks the outgoing range and rejects the push if any commit fails verification:
# .husky/pre-push — installed and version-controlled via Husky
# Reject a push whose range contains any commit that does not
# carry a good ("G") signature.
remote="$1"
zero="0000000000000000000000000000000000000000"
while read -r local_ref local_sha remote_ref remote_sha; do
# Skip branch deletions (local_sha is all zeros)
[ "$local_sha" = "$zero" ] && continue
if [ "$remote_sha" = "$zero" ]; then
# New branch on the remote: check every reachable-but-unpushed commit
range="$local_sha"
range_args="$local_sha --not --remotes=$remote"
else
range_args="$remote_sha..$local_sha"
fi
# %G? prints G (good), B (bad), U (unknown validity), N (no signature)...
bad=$(git rev-list "$range_args" | while read -r sha; do
status=$(git show --no-patch --format="%G?" "$sha")
case "$status" in
G|U) : ;; # accept good or trusted-unknown
*) echo "$sha $status" ;; # flag everything else
esac
done)
if [ -n "$bad" ]; then
echo "pre-push: refusing to push unsigned/invalid commits:" >&2
echo "$bad" >&2
echo "Run: git rebase --exec 'git commit --amend --no-edit -S' origin/main" >&2
exit 1
fi
done
exit 0 Verify: Create an unsigned commit and confirm the hook blocks the push:
git commit --no-gpg-sign --allow-empty -m "test: unsigned commit"
git push origin HEAD
# Expected: pre-push: refusing to push unsigned/invalid commits:
# <sha> N SAFETY WARNING: Local hooks are convenience, not enforcement —
git push --no-verifyskips every client-side hook, and a fresh clone ships with no hooks at all until Husky’spreparescript runs. Never treat Step 1 as your security boundary; the authoritative gates are server-side. Recovery if you rely on it by mistake: re-sign the range withgit rebase --exec 'git commit --amend --no-edit -S' <base>and force-push through the normal review flow.
Step 2 — Require Signed Commits via Branch Protection Jump to heading
Intent: Make the hosting platform refuse to merge any pull request whose commits are not signed with a key it can verify. This is the first gate the developer cannot disable.
On GitHub the toggle is Settings → Branches → require signed commits, but manage it as code so the rule is version-controlled and auditable. The Terraform github_branch_protection resource below turns on signature enforcement alongside the usual review and status-check gates:
# terraform/commit_verification.tf
resource "github_branch_protection" "main" {
repository_id = var.repository_id
pattern = "main"
require_signed_commits = true # Reject any commit without a verified signature
enforce_admins = true # No bypass — admins are held to the same rule
allows_force_pushes = false
allows_deletions = false
required_status_checks {
strict = true
contexts = [
"verify-signatures", # CI job that checks signer identity (see Step 4)
]
}
required_pull_request_reviews {
required_approving_review_count = 1
require_code_owner_reviews = true
}
} require_signed_commits checks only that a valid signature is present and that the platform can map the key to an account — it does not assert that the signer is authorized. That identity check is a separate concern handled by the CI job wired into required_status_checks above and detailed in Verifying Signed Commits in CI Pipelines.
Verify: Push an unsigned commit to a feature branch and open a pull request:
git commit --no-gpg-sign --allow-empty -m "chore: unsigned"
git push origin feature/unsigned-test
# In the PR, the commit shows an "Unverified" badge and the
# merge button is blocked with: "Required signatures are not present."
gh pr checks # confirm the merge is blocked SAFETY WARNING: Setting
enforce_admins = falsesilently exempts every administrator from the signature requirement, so a single admin push breaks the guarantee that “all history onmainis verified” — and does so without any status check turning red. Keep ittrue. If you genuinely need an emergency bypass, flip it with a logged, time-boxed Terraform change and revert immediately:terraform applyto disable, land the fix, thenterraform applyagain to restore, leaving the state history as the audit trail.
Step 3 — Enforce Signatures with a Server-Side Pre-Receive Hook Jump to heading
Intent: On a self-hosted Git server, reject an unsigned push to any ref — not just protected branches — before the objects are written. This closes the gap branch protection leaves open on tags, feature branches, and mirror pushes.
Branch protection only guards the branches you enumerate and only on the platform’s merge path. A pre-receive hook runs on the server for every incoming push, sees the full list of ref updates, and can refuse the entire transaction atomically. Install it on a bare repository at hooks/pre-receive (or, for Gitea/GitLab, through their server-hook mechanism):
#!/usr/bin/env bash
# hooks/pre-receive — runs on the Git server for every push.
# Rejects the whole push if any new commit lacks a valid signature.
set -euo pipefail
zero="0000000000000000000000000000000000000000"
# allowed_signers maps principals to public keys for identity checks.
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="gpg.ssh.allowedSignersFile"
export GIT_CONFIG_VALUE_0="/etc/git/allowed_signers"
fail=0
while read -r old_sha new_sha ref_name; do
# Skip deletions
[ "$new_sha" = "$zero" ] && continue
if [ "$old_sha" = "$zero" ]; then
# New ref: check every commit not already present on another ref
commits=$(git rev-list "$new_sha" --not --all)
else
commits=$(git rev-list "$old_sha..$new_sha")
fi
for sha in $commits; do
# verify-commit exits non-zero on missing or bad signatures
if ! git verify-commit "$sha" >/dev/null 2>&1; then
echo "pre-receive: rejected $ref_name — commit $sha is not verified" >&2
fail=1
fi
done
done
exit "$fail" Make the hook executable and confirm the server picks it up:
chmod +x hooks/pre-receive
# The hook runs inside the bare repo; test config resolution:
git config --get gpg.ssh.allowedSignersFile # should print the path when GIT_CONFIG_* is set Verify: From a clone, attempt to push an unsigned commit to a non-protected ref such as a tag or scratch branch:
git commit --no-gpg-sign --allow-empty -m "test: unsigned to server"
git push origin HEAD:refs/heads/scratch/unsigned
# Expected: remote: pre-receive: rejected refs/heads/scratch/unsigned —
# remote: commit <sha> is not verified
# Expected: ! [remote rejected] HEAD -> scratch/unsigned (pre-receive hook declined) SAFETY WARNING: A
pre-receivehook that errors out — a missingallowed_signersfile, a non-zero exit from an unexpected code path, or aset -etripping on an emptyrev-list— will reject every push and lock the whole team out of the server. Test on a staging mirror first, keep the logic defensive (quote variables, handle empty ranges), and retain out-of-band shell access so you can move the broken hook aside withmv hooks/pre-receive hooks/pre-receive.disabledto restore pushes while you debug.
Step 4 — Verify Signatures in the Merge Queue Jump to heading
Intent: Ensure the final commit that lands on main — often a merge or squash commit the platform creates on your behalf — is itself signed and authored by an authorized key. This is the last gate before protected history.
A merge queue batches pull requests, rebases them onto the current tip, and produces a new commit. That new commit is created by the platform, not the developer, so it must be signed by a platform key you trust, and the queue’s speculative combination of PRs must re-verify every commit it stacks. Wire a required status check that verifies both the incoming commits and the resulting merge object:
# .github/workflows/verify-signatures.yml
name: verify-signatures
on:
merge_group: # runs on each merge-queue candidate
pull_request:
branches: [main]
jobs:
verify-signatures:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history so the range is resolvable
- name: Verify every commit carries an authorized signature
run: |
# allowed_signers ships in the repo; enforce identity, not just presence
git config gpg.ssh.allowedSignersFile .github/allowed_signers
# base.sha is set on pull_request; fall back to the merge-queue base
base="$"
base="${base:-$}"
head="$"
fail=0
for sha in $(git rev-list "$base..$head"); do
if ! git verify-commit "$sha" 2>/dev/null; then
echo "::error::commit $sha has no authorized signature"
fail=1
fi
done
exit "$fail" Because this job is listed in the branch’s required_status_checks, the merge queue will not advance a batch until it is green, and — with strict = true — the candidate is verified against the current tip rather than a stale base. For the deeper CI patterns (allowlist management, keyless verification with Sigstore, handling web-flow keys), see Verifying Signed Commits in CI Pipelines.
Verify: Add a PR to the merge queue whose head commit is signed with a key absent from allowed_signers:
git verify-commit HEAD
# Expected on an unauthorized key:
# error: gpg.ssh.allowedSignersFile ... no principal matched
# The merge_group check fails and the queue drops the candidate. Integration with Adjacent Workflows Jump to heading
Verification gates do not exist in isolation — they inherit configuration from the signing setup and hand off enforcement to the CI and branching layers around them.
Signing method determines what every gate actually verifies. Whether the team signs with GPG or SSH keys changes the gpg.format setting, the shape of the allowed_signers file, and how the platform maps a key to an identity; GPG vs SSH Commit Signing covers that decision and its downstream effects on the gates above.
CI verification is the identity half of the story. require_signed_commits proves a signature exists; a CI job proves the signer is allowed. The two compose: branch protection blocks the merge button, and the required signed-commit CI pipeline supplies the pass/fail signal that gate reads. Never rely on presence alone when identity is what you actually care about.
Branching model decides which refs need protection at all. A trunk-based development setup concentrates all integration on main, so a single protected pattern plus the merge-queue gate covers nearly everything; a release-branch model needs the pre-receive hook to catch the long-lived side branches that branch protection patterns often miss.
Local hook engineering shares its plumbing with the pre-push signature guard. The same pre-push validation rules that block broken builds and leaked secrets can carry the signature check from Step 1, so developers hit one fast local gate rather than three. Keep the local layer thin — presence and format checks only — and leave authoritative identity verification to the server.
The boundary of responsibility is the same as everywhere in this stack: the developer’s machine gives fast feedback, the server enforces. Any gate a developer can disable is advisory by definition.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| PR commit shows “Unverified” despite being signed | The signer’s public key is not registered on the platform account, or the committer email does not match the key’s identity | Add the signing key to the account; ensure user.email matches a verified account email |
pre-receive hook rejects every push, including signed ones | Hook errors before verification — missing allowed_signers file or an unquoted variable tripping set -e | Move the hook aside (mv hooks/pre-receive hooks/pre-receive.disabled), fix path/quoting, test on a mirror, re-enable |
require_signed_commits still lets admins push unsigned | enforce_admins is false | Set enforce_admins = true in the github_branch_protection resource and terraform apply |
| Merge-queue commit itself is “Unverified” | Platform web-flow merge key not in allowed_signers | Add the platform’s web-flow public key to the allowlist, or configure the platform to sign merges with a trusted key |
git verify-commit fails in CI but passes locally | CI checkout is shallow, so the commit’s parent range is unresolvable | Set fetch-depth: 0 on the checkout step |
Husky pre-push guard never runs | Hooks not installed after clone (prepare script did not execute) | Run npm install to trigger Husky, or git config core.hooksPath .husky manually |
Frequently Asked Questions Jump to heading
If branch protection already requires signed commits, why add a pre-receive hook? Jump to heading
Branch protection only guards the branches you name and only on the hosting platform’s web merge path. A pre-receive hook on a self-hosted Git server rejects an unsigned push to any ref — including tags and scratch branches — before the object is written to the repository. On a self-hosted mirror that is not fronted by the platform’s protection rules, the pre-receive hook may be the only server-side gate. The two layers cover different bypass paths, which is why security-sensitive repositories run both.
Can a developer bypass the local pre-push signature check? Jump to heading
Yes, trivially. Any client-side hook is skipped with git push --no-verify, and hooks are not shared through a clone unless a tool like Husky reinstalls them. Treat local hooks as fast developer feedback, not enforcement. The authoritative gate is always server-side — branch protection, a pre-receive hook, or merge-queue verification — none of which the developer’s machine controls.
Does require_signed_commits verify who signed the commit? Jump to heading
No. The require_signed_commits setting only checks that a valid signature is present and that the platform can associate its key with some account. It does not enforce that the signer is an authorized member of your team. To assert identity you must verify signatures against an allowed_signers file or a key allowlist, typically in the CI job wired into required_status_checks or in the pre-receive hook.
Should enforce_admins be enabled for signed-commit protection? Jump to heading
Yes, for any repository where the signature guarantee must hold universally. With enforce_admins = false, any administrator can push an unsigned commit directly, silently breaking the audit chain — and no status check will flag it. Enable it, and provide a narrow, logged break-glass path (a time-boxed Terraform change) for the rare legitimate emergency instead of leaving a standing bypass.
How do I verify a merge commit created by the platform is itself signed? Jump to heading
Configure the platform to sign its own merge and squash commits with a web-flow key, then add that key to your allowed_signers list. GitHub signs web-created merge commits automatically with its web-flow key; for self-hosted platforms, verify the resulting merge commit in a post-merge CI job with git verify-commit HEAD and fail the pipeline if the signature is missing or unauthorized.
Related Jump to heading
- Enforcing Signed Commits with Branch Protection — a focused walkthrough of the platform branch-protection gate, from the UI toggle to the Terraform resource and its bypass audit
- Commit Signing & Git Supply-Chain Security — the parent practice covering signing keys, verification, and supply-chain provenance end to end
- Verifying Signed Commits in CI Pipelines — the CI half of enforcement: proving signer identity against an allowlist, not just signature presence
- GPG vs SSH Commit Signing — choosing a signing method and how that choice shapes every gate in this guide
- Pre-Push Validation Rules — the local hook engineering that carries the Step 1 signature guard alongside build and secret checks