Every software supply chain compromise that starts inside a GitHub repository follows the same pattern: someone pushed directly to main, a single reviewer rubber-stamped a risky pull request, or a CI check was skipped under deadline pressure. If you want to configure GitHub branch protection rules correctly, you're not just checking a compliance box — you're closing the exact gap attackers and careless commits both exploit. This guide walks through setting up branch protection on a real repository, from opening the settings page to verifying the rules actually block what they're supposed to block.
By the end, you'll have a main branch that requires reviewed, status-checked pull requests; blocks force-pushes and deletions; and enforces the rules on administrators too — not just regular contributors. We'll also cover how these settings tie into broader repo security settings and where teams most often get the configuration wrong.
Step 1: Open Your Repository's Branch Protection Settings
Start in the GitHub UI. Navigate to your repository, then:
Settings → Branches → Branch protection rules → Add branch protection rule
For the "Branch name pattern" field, enter the branch you want to protect — typically main or master. You can also use wildcards like release/* to cover every release branch with one rule, which is worth doing early so you don't end up protecting main while a release/2.x branch sits wide open.
If you manage many repositories, doing this by hand doesn't scale. The GitHub CLI and REST API let you script it:
gh api repos/OWNER/REPO/branches/main/protection \
--method PUT \
--input protection.json
We'll build out protection.json as we go through the remaining steps.
Step 2: Configure GitHub Branch Protection Rules for the Default Branch
This is the core rule set. At minimum, enable:
- Require a pull request before merging — no direct commits to
main. - Require status checks to pass before merging — CI, linting, and security scans must pass.
- Require branches to be up to date before merging — prevents merging a PR that was tested against a stale base.
- Include administrators — this single checkbox is the one most teams skip, and it's the one that matters most. Without it, admins (and often the CI service account with admin scope) can bypass every rule you just set.
In JSON form, via the REST API:
{
"required_status_checks": {
"strict": true,
"contexts": ["ci/build", "ci/test", "security/sast-scan"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 2,
"dismiss_stale_reviews": true
},
"restrictions": null
}
Apply it with the gh api command from Step 1. Note that restrictions: null is required by the API even when you don't intend to restrict push access yet — GitHub rejects the payload without the key present.
Step 3: Require Pull Request Reviews Before Merging
This is where "require pull request reviews GitHub" settings deserve their own pass, because the defaults are weaker than most teams assume. Under Require a pull request before merging, configure:
- Required number of approvals: 1 is the GitHub default; 2 is a more defensible baseline for anything touching production infrastructure, build tooling, or dependency manifests (
package.json,requirements.txt,go.mod, CI YAML). - Dismiss stale pull request approvals when new commits are pushed: without this, an approved PR can be silently amended post-approval — a technique used in several real supply chain incidents where a benign-looking diff was swapped for a malicious one after review.
- Require review from Code Owners: pairs with a
CODEOWNERSfile so changes to sensitive paths (/ci,/.github/workflows,/infra) always route to the right reviewers, not whoever happens to approve first. - Require approval of the most recent reviewable push: closes the gap where the last commit before merge was never actually seen by a reviewer.
A minimal CODEOWNERS entry to pair with this:
/.github/workflows/ @your-org/platform-security
/infra/ @your-org/platform-security
Step 4: Require Status Checks to Pass Before Merging
Branch protection is only as strong as the checks it requires. List every check that must be green, and make sure the names match your CI configuration exactly — GitHub matches on the literal context string, so a renamed workflow job silently stops being enforced.
"required_status_checks": {
"strict": true,
"contexts": [
"ci/build",
"ci/unit-tests",
"security/dependency-scan",
"security/sast-scan",
"security/secret-scan"
]
}
"strict": true forces the branch to be up to date with main before merge — without it, a PR can merge after passing checks against an outdated base, which reintroduces the exact race condition status checks exist to prevent. If you run a secret-scanning or SCA tool as part of CI, add its check here; a scan that runs but doesn't gate the merge is advisory, not protective.
Step 5: Restrict Who Can Push and Force-Push
Even with reviews and checks required, you still want to control who can push directly to protected branches at all (relevant for automation accounts and hotfix workflows) and eliminate history rewrites:
- Restrict who can push to matching branches: limit to a small deploy/release team or bot account, not "everyone with write access."
- Do not allow force pushes: leave this unchecked/disabled. A force-push to
maincan silently drop commits, including security fixes, and rewrite audit history that compliance frameworks (SOC 2, ISO 27001) expect to be immutable. - Do not allow deletions: prevents a protected branch from being deleted outright, which has happened accidentally via automation scripts with overly broad permissions.
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false
Step 6: Lock Down Administrative Overrides and Tags
Two commonly missed edges:
- Enforce for admins. Confirmed above, but worth re-checking after any org ownership change — new admins are sometimes added without the setting carrying over on repos created from templates.
- Protect tags, not just branches. If you cut releases from tags, an unprotected tag can be deleted and recreated pointing at a different commit — a classic tag-hijacking technique for slipping malicious code into a "trusted" release. GitHub's tag protection rules (
Settings → Tags → New rule) let you lock tag patterns likev*so only specific roles can create or delete them.
gh api repos/OWNER/REPO/tags/protection \
--method POST \
-f pattern='v*'
Step 7: Review Repo Security Settings Holistically
Branch protection is one layer. Round out your GitHub repo security settings while you're in the Settings tab:
- Require signed commits (
Settings → Branches → Require signed commits) to establish commit provenance. - Enable Dependabot alerts and security updates so dependency vulnerabilities surface automatically.
- Turn on secret scanning and push protection to stop credentials from ever landing in the repo.
- Set default branch permissions to least privilege — audit who has write/admin access org-wide, not just per repository.
These best practices compound: a reviewed, status-checked PR into a signed, protected main branch, scanned for secrets and dependency risk before merge, is a materially different security posture than branch protection alone.
Troubleshooting and Verification
Once configured, verify the rules actually hold before trusting them:
- Test with a throwaway branch. Try pushing directly to
mainfrom the command line — it should be rejected withremote: error: GH006: Protected branch update failed. - Confirm the admin checkbox took effect. Have an org admin attempt to merge a PR with a failing check. If it succeeds,
enforce_adminsisn't actually enabled — re-check viagh api repos/OWNER/REPO/branches/main/protectionand confirm"enforced": trueunderenforced_admins. - Check status check name mismatches. If a PR shows "Expected — Waiting for status to be reported" indefinitely, the context string in your branch protection rule doesn't match the job name in your workflow file. Compare
required_status_checks.contextsagainst the exactjobs.<id>.nameor check run name in your CI config. - Audit stale rules after renaming default branches. Migrating from
mastertomain(or vice versa) doesn't move branch protection rules automatically — you must recreate them against the new branch name pattern. - Watch for rule conflicts with GitHub Rulesets. If your organization also uses the newer repository/organization-level Rulesets feature alongside classic branch protection, the stricter of the two applies per setting — audit both under
Settings → Rules → Rulesetsto avoid confusing overlaps.
How Safeguard Helps
Manually configuring branch protection rules repo-by-repo works until you have more than a handful of repositories — and it silently drifts even then, as new repos get created without the standard configuration and existing rules get loosened "just this once" under deadline pressure. Safeguard continuously monitors your GitHub organization's actual branch protection state against your intended policy — flagging repositories where required reviews, status checks, or admin enforcement have drifted out of compliance — and correlates that posture with the rest of your software supply chain, including CI/CD pipeline configuration, dependency risk, and signed-commit coverage. Instead of finding out a critical repo's main branch was unprotected after an incident, Safeguard surfaces the gap the moment it appears, with the specific repository, rule, and remediation step attached — turning branch protection from a one-time setup task into an enforced, auditable baseline across every repository your organization owns.