Enforcing signed commits with branch protection Jump to heading
A commit’s author field is free text — anyone can set user.name and user.email to impersonate a colleague and push. Commit signing closes that gap by binding each commit to a cryptographic key, but generating signatures locally is worthless if the remote accepts unsigned commits anyway. The enforcement point is GitHub’s Require signed commits branch-protection rule (or GitLab’s Reject unsigned commits push rule): the server refuses any commit that lacks a signature it can verify. This page is a focused recipe within Commit Verification Gates, which covers the full taxonomy of server-side controls that reject unverified commits before they reach a protected branch.
When to use this approach Jump to heading
Apply this recipe when:
- Your protected branches feed production deploys and you need every commit’s provenance to be cryptographically attributable, not just claimed in an author field.
- Contributors already produce verified signatures locally — configured via GPG vs SSH Commit Signing — and you want the remote to reject anything unsigned.
- You run trunk-based development, where a single long-lived branch is the deploy source and a single unsigned commit is enough to break the provenance chain.
- You want a hard server-side gate that no local
--no-verifyor misconfigured hook can circumvent, complementing the deeper checks in Verifying Signed Commits in CI Pipelines.
This recipe is not a complete supply-chain control on its own. GitHub’s rule verifies that a signature exists and is parseable — it does not verify that the signing key belongs to an authorized author. Author-to-key binding, revocation checks, and Sigstore transparency-log validation belong in a CI gate, not in branch protection. Enable this rule as the coarse first gate, then layer signature-identity verification on top.
The diagram below shows where the branch-protection check sits relative to the local signing step and the deeper CI verification gate.
Step-by-step recipe Jump to heading
Step 1 — Confirm contributors can produce verified signatures Jump to heading
Enabling the rule before contributors can sign will lock everyone out of the branch. Verify locally first that a commit is signed and that GitHub will accept it:
# Sign a throwaway commit and inspect the signature
git commit --allow-empty -S -m "signing smoke test"
# %G? prints G (good), U (good, untrusted), N (none), B (bad), E (error)
git log -1 --format='%G? %GS' A G or U means the commit carries a signature Git can parse. Now confirm the public key is registered on GitHub so the platform will mark it Verified rather than Unverified:
# List keys the account has uploaded — the signing key must appear here
gh api /user/gpg_keys --jq '.[].key_id'
gh api /user/ssh_signing_keys --jq '.[].key' If the key is absent, upload it before proceeding — see GPG vs SSH Commit Signing for the full key-generation and upload flow.
Step 2 — Enable Require signed commits in the GitHub UI Jump to heading
The interactive path is the fastest way to enable the rule for a single repository:
- Open the repository, then Settings → Branches.
- Under Branch protection rules, click Add branch ruleset (or edit the existing rule for your default branch).
- Set the Branch name pattern to the protected branch, for example
main. - Enable Require signed commits.
- Enable Do not allow bypassing the above settings (the ruleset equivalent of
enforce_admins) so admins are also held to the rule. - Click Create / Save changes.
Verify the rule is live by reading it back through the API rather than trusting the UI state:
# Should print true for required_signatures.enabled
gh api /repos/:owner/:repo/branches/main/protection \
--jq '.required_signatures.enabled' Step 3 — Enable the same rule with gh api Jump to heading
For scripted or bulk rollout across many repositories, drive the classic branch-protection endpoint directly. Signed-commit enforcement has its own dedicated sub-resource:
# Enable Require signed commits on main
# The Accept header opts in to the signatures preview media type
gh api --method POST \
-H "Accept: application/vnd.github.sigstore-protection-preview+json" \
/repos/OWNER/REPO/branches/main/protection/required_signatures If the branch has no protection object yet, create one first — required_signatures can only be set on an already-protected branch. This call also sets enforce_admins so the rule is not silently skipped for owners:
# Minimal protection object with admin enforcement turned on
gh api --method PUT /repos/OWNER/REPO/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null
}
JSON
# Then turn on required signatures
gh api --method POST \
-H "Accept: application/vnd.github.sigstore-protection-preview+json" \
/repos/OWNER/REPO/branches/main/protection/required_signatures Verify both flags in one read:
gh api /repos/OWNER/REPO/branches/main/protection \
--jq '{signed: .required_signatures.enabled, admins: .enforce_admins.enabled}'
# Expected: {"signed":true,"admins":true} Step 4 — Codify the protection rule in Terraform Jump to heading
Click-ops drifts. For any repository under infrastructure-as-code, declare the rule so it is reviewable, auditable, and self-healing. The github_branch_protection resource exposes require_signed_commits directly:
# versions.tf — pin the provider
terraform {
required_providers {
github = {
source = "integrations/github"
version = "~> 6.0"
}
}
}
# branch-protection.tf
resource "github_branch_protection" "main" {
repository_id = "REPO" # repo name or node_id
pattern = "main"
# The signing gate:
require_signed_commits = true
# Hold admins to the same rule — see the SAFETY WARNING below
enforce_admins = true
# Common companions; tune to your workflow
required_pull_request_reviews {
required_approving_review_count = 1
}
} Apply and verify the plan converges to no changes on a second run — proof that remote state matches the declaration:
terraform apply
# Re-plan should report: No changes. Your infrastructure matches the configuration.
terraform plan -detailed-exitcode # exit code 0 == no drift SAFETY WARNING: With
enforce_admins = false(the Terraform default isfalse), anyone holding admin permission can push unsigned commits straight to the protected branch and the rule silently does not apply to them — leaving a bypass hole exactly where the highest-privilege accounts operate. Setenforce_admins = true. If this locks you out mid-migration, temporarily flip it back withgh api --method DELETE /repos/OWNER/REPO/branches/main/protection/enforce_admins, land the signed commit, then re-enable it.
Step 5 — Verify by pushing an unsigned commit Jump to heading
Never trust that the gate works — prove it rejects. Create a deliberately unsigned commit and push it to the protected branch:
# Force an unsigned commit regardless of your global signing config
git commit --allow-empty --no-gpg-sign -m "unsigned canary — should be rejected"
git push origin HEAD:main The push must be refused server-side with a message resembling:
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Commits must have verified signatures.
! [remote rejected] HEAD -> main (protected branch hook declined) Now confirm the positive path: a properly signed commit is accepted. Clean up the canary first so it never lands:
git reset --hard HEAD~1 # drop the unsigned canary
git commit --allow-empty -S -m "signed commit — should pass"
git push origin HEAD:main # succeeds; commit shows Verified SAFETY WARNING: Branch protection distinguishes only verified from unverified signatures — it has no equivalent of GPG’s vigilant mode, which the platform applies at the account/UI level to mark commits whose author does not match a known key. A commit signed with any key GitHub can verify passes the gate even if the author email is spoofed. Do not treat a green Verified badge as proof of authorship; enforce author-to-key binding in CI.
Step 6 — Apply the GitLab push-rules equivalent Jump to heading
GitLab expresses the same control as a push rule rather than a branch-protection toggle, and it applies to all protected branches at once. In the UI: Settings → Repository → Push rules → Reject unsigned commits. Via the API:
# Enable at the project level (Premium/Ultimate feature)
curl --request PUT \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/:id/push_rule" \
--data "reject_unsigned_commits=true" Verify it took effect:
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/:id/push_rule" \
| grep -o '"reject_unsigned_commits":[a-z]*'
# Expected: "reject_unsigned_commits":true Note the semantic difference: GitLab’s push rule rejects at receive time for every protected branch and, unlike GitHub, can be paired with reject_non_dco_certified_commits and committer-email regex on the same object.
Validation checklist Jump to heading
Before considering the gate production-ready, confirm each item:
Frequently asked questions Jump to heading
Does Require signed commits check whether the signing key is trusted? Jump to heading
No. The rule rejects only commits with no signature or a signature GitHub cannot cryptographically parse. It does not confirm the key belongs to the person named in the author field — a commit signed with any key that verifies against the pusher’s uploaded keys passes the gate. Treat the rule as a presence-and-parseability check, and enforce author-to-key binding and revocation status in a dedicated CI step.
Why do older commits show Unverified after I turn the rule on? Jump to heading
The requirement applies only to commits pushed to the protected branch after it is enabled. Existing history is never re-evaluated or rewritten, so a branch can hold a mix of verified and unverified commits. Watch merge paths specifically: a merge that introduces old unsigned commits from an unprotected side branch can still land unless every branch that feeds the protected branch is also protected.
Can a repository admin bypass Require signed commits? Jump to heading
Yes, unless enforce_admins is true. When it is false — the default for the Terraform resource and for a freshly created protection object — users with admin permission can push unsigned commits directly and the rule simply does not apply to them. Set enforce_admins = true (or, in rulesets, enable “Do not allow bypassing the above settings”) so the signing requirement covers everyone, including repository and organization owners.
Related Jump to heading
- Commit Verification Gates — the parent page covering the full set of server-side controls that reject unverified commits before they reach a protected branch.
- Verifying Signed Commits in CI Pipelines — the sibling gate that adds author-to-key binding and revocation checks on top of the coarse branch-protection rule.
- Trunk-Based Development Setup — the single-protected-branch workflow where signed-commit enforcement matters most, since every deploy traces to one continuously integrated line.