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.

Layered commit verification gatesA developer's commit passes through four sequential gates: a local pre-commit and pre-push hook, platform branch protection requiring signed commits, a server-side pre-receive hook, and merge-queue verification. Each gate rejects an unsigned commit, and each has a bypass path that the next gate covers.devgit commitGate 1pre-commitpre-pushGate 2branchprotectionGate 3pre-receiveserver-sideGate 4mergequeue→ mainunsigned commit rejected at any gate--no-verifyunprotected ref / tag / mirror pushplatform merge commit unsigned

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-verify skips every client-side hook, and a fresh clone ships with no hooks at all until Husky’s prepare script 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 with git 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 = false silently exempts every administrator from the signature requirement, so a single admin push breaks the guarantee that “all history on main is verified” — and does so without any status check turning red. Keep it true. If you genuinely need an emergency bypass, flip it with a logged, time-boxed Terraform change and revert immediately: terraform apply to disable, land the fix, then terraform apply again 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-receive hook that errors out — a missing allowed_signers file, a non-zero exit from an unexpected code path, or a set -e tripping on an empty rev-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 with mv hooks/pre-receive hooks/pre-receive.disabled to 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

SymptomLikely causeFix
PR commit shows “Unverified” despite being signedThe signer’s public key is not registered on the platform account, or the committer email does not match the key’s identityAdd the signing key to the account; ensure user.email matches a verified account email
pre-receive hook rejects every push, including signed onesHook errors before verification — missing allowed_signers file or an unquoted variable tripping set -eMove 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 unsignedenforce_admins is falseSet 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_signersAdd 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 locallyCI checkout is shallow, so the commit’s parent range is unresolvableSet fetch-depth: 0 on the checkout step
Husky pre-push guard never runsHooks 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.