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-verify or 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.

Signed-commit enforcement across local signing, branch protection, and CI verificationDiagram showing a signed commit pushed from a developer machine, intercepted first by GitHub's Require signed commits branch-protection rule which rejects unsigned commits, then passing to a CI verification gate that checks key-to-author binding before the branch is considered protected.Local commitcommit.gpgsignBranch protectionrequire signed commitsenforce_adminsrejects unsignedunsigned pushrejectedfailpassCI verifykey ↔ authorProtectedbranch

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:

  1. Open the repository, then Settings → Branches.
  2. Under Branch protection rules, click Add branch ruleset (or edit the existing rule for your default branch).
  3. Set the Branch name pattern to the protected branch, for example main.
  4. Enable Require signed commits.
  5. Enable Do not allow bypassing the above settings (the ruleset equivalent of enforce_admins) so admins are also held to the rule.
  6. 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 is false), 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. Set enforce_admins = true. If this locks you out mid-migration, temporarily flip it back with gh 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.

  • 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.