Verifying signatures on merge commits made by the forge Jump to heading
A team turns on signature enforcement, every engineer signs their commits, and the first audit of the default branch still shows dozens of commits the team’s keys never touched. They are the merge commits, squash commits and rebased copies produced when someone clicks the merge button. The forge created those objects on its own servers, so either the forge signed them with its own key or nobody did. Understanding which, and deciding whether to trust that key, is the gap most signing rollouts leave open. This page closes it, within the wider topic of commit verification gates.
When to use this approach Jump to heading
- Your default branch receives changes through the forge’s merge button rather than through direct pushes.
- An audit script or CI gate verifies every commit on the branch and is reporting forge-made commits as unknown or unsigned.
- You use squash or rebase merging, which creates brand-new commits whose author is the contributor but whose committer is the forge.
- You enforce signatures with a pre-receive hook on a self-hosted server and need web merges to pass it.
- If your team merges locally and pushes signed merge commits, none of this applies; the merge commit is signed by the person who made it.
Step 1 — Identify which commits the forge created Jump to heading
Forge-made commits share a committer identity that differs from the author. Listing commits where author and committer disagree is the quickest way to see the scale of the problem on an existing branch.
# Commits on main where the committer is not the author, with signature status
git log --first-parent main --since=90.days \
--format='%h %G? committer=%ce author=%ae' |
awk '{split($3,c,"="); split($4,a,"="); if (c[2]!=a[2]) print}' On GitHub the committer for web merges is [email protected]; GitLab uses the instance’s configured identity; Gitea and Forgejo use the instance’s signing configuration. The %G? column tells you whether those commits carry a signature at all.
# Verification: how many first-parent commits are forge-made versus pushed
git log --first-parent main --since=90.days --format='%ce' | sort | uniq -c | sort -rn Step 2 — Know what each merge method produces Jump to heading
The three merge buttons create different objects, and the signature question differs for each.
This is the decision that matters most. With merge commits, the contributor’s signed commits remain reachable through the second parent, and the forge’s commit adds only the merge itself. With squash or rebase, the contributor’s signatures are gone from the branch, and the only signature left is the forge’s — so the forge’s key is effectively vouching for the content. The detailed trade-off between strategies is in the squash vs merge vs rebase decision matrix, and the next guide in this topic, keeping signatures valid through rebase and squash, covers the workarounds.
# Verification: on a merge commit, the contributor's signed commits are still reachable
git log --format='%h %G? %an' "$MERGE^1..$MERGE^2" Step 3 — Obtain and pin the forge’s signing key Jump to heading
Hosted forges publish the key they sign web commits with. GitHub publishes a GPG key for web-flow; self-hosted Gitea, Forgejo and GitLab instances sign with a key the administrator configures. Fetch it once, record its fingerprint, and pin it — do not fetch it fresh on every verification run, or a compromised download path becomes a way to inject trust.
# GitHub's web-flow key, fetched once and pinned
curl -fsSL https://github.com/web-flow.gpg -o forge-web-flow.gpg
gpg --show-keys --with-fingerprint forge-web-flow.gpg
# Record the fingerprint in the repository that holds your trust material
gpg --show-keys --with-colons forge-web-flow.gpg | awk -F: '/^fpr/{print $10; exit}' > forge-web-flow.fpr For a self-hosted instance using SSH signing, the administrator’s signing public key goes into your allowed signers file under a principal that matches the committer email the instance uses.
# Verification: a recent forge merge commit verifies against the pinned key alone
GNUPGHOME=$(mktemp -d) gpg --import forge-web-flow.gpg
GNUPGHOME=$GNUPGHOME git verify-commit "$MERGE" && echo "forge signature ok" Step 4 — Decide how much the forge key is trusted for Jump to heading
Trusting the forge’s key unconditionally means any commit with that signature passes. That is reasonable only if the forge is the sole path that can produce such commits — which is true, since only the forge holds the private key — and if the forge only creates commits after its own checks pass. The safer posture is a narrow trust: accept the forge’s signature only on commits whose parents are themselves acceptable.
# Narrow trust in an audit: merge commits must have signed second-parent chains
for m in $(git rev-list --merges --first-parent main --since=30.days); do
bad=$(git log --format='%G?' "$m^1..$m^2" | grep -vc '^G$' || true)
[ "$bad" -eq 0 ] || echo "$m: $bad unsigned contributor commits behind a forge merge"
done Step 5 — Teach your gates about the forge identity Jump to heading
Every verifier needs the same rule. In a self-hosted pre-receive hook, add the forge’s key with a dedicated principal. In a CI range check, import the pinned key into a throwaway keyring before verifying. In a local developer setup, import it so git log --show-signature stops reporting forge commits as unknown.
# CI step: import the pinned forge key into an isolated keyring, then verify the range
export GNUPGHOME="$RUNNER_TEMP/gnupg"; mkdir -p -m 700 "$GNUPGHOME"
gpg --import trust/forge-web-flow.gpg
gpg --import-ownertrust <<EOF
$(cat trust/forge-web-flow.fpr):6:
EOF
git log --format='%H %G?' "$BASE..$HEAD" | awk '$2!="G"{print; bad=1} END{exit bad}' The ownertrust line marks the pinned key as ultimately trusted so its signatures report G instead of U. Without it, GPG verifies the signature mathematically but reports unknown validity, and a strict gate rejects it.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does GitHub’s “Require signed commits” setting accept web merges? Jump to heading
Yes. The forge treats its own web-flow signature as valid, so merges made through the interface satisfy the rule. Your own verifiers do not share that assumption until you import and trust the key, which is why audits disagree with the branch-protection badge.
If we squash-merge, is there any record of the contributor’s signature? Jump to heading
The pull request’s original commits remain on the forge as long as the pull request exists, and the head branch may still exist on the remote. Neither is part of the default branch’s history, so a clone-based audit cannot see them. If you need the contributor’s signature to be part of history, use merge commits.
Can the forge sign with our organisation’s key instead of its own? Jump to heading
Self-hosted forges can be configured with any signing key you supply, including one dedicated to your organisation. Hosted forges sign with their platform key. Either way, treat it as a service identity with its own principal, never as a substitute for contributor signatures.
Related Jump to heading
- Commit Verification Gates — the parent topic.
- Keeping Signatures Valid Through Rebase and Squash — what to do when your merge method discards signatures.
- Verifying a Range of Commits in CI, Not Just the Tip — the CI gate that needs the forge key imported.
- Enforcing Signed Commits with Branch Protection — the forge-side rule this page complements.