Pillar 3 covers branch protection as a collaboration mechanism — how to configure it, what each option does, how it shapes a team’s workflow.
This page asks a narrower question: as a security control, what does branch protection actually prevent, and what walks straight past it?
The answer matters because branch protection is routinely cited as the reason a repository is safe, and the gap between what teams believe it enforces and what it enforces is where incidents live.
The short answer
Section titled “The short answer”Branch protection constrains what may happen to a specific ref. It does not constrain who holds credentials, what those credentials can do elsewhere, or whether the change that lands is any good.
As a security control it closes exactly one class of path: an identity with write access modifying a protected branch without going through the required process. That is a valuable class to close. It is also considerably narrower than “the repository is protected”.
Three rules do most of the work:
- Require a pull request before merging — closes direct pushes
- Block force pushes — closes history rewriting
- Restrict deletions — closes branch removal
Everything else adds conditions to the merge; those three define the boundary.
What each rule closes
Section titled “What each rule closes”Read this as a threat table rather than a settings reference. The right-hand column is where the next layer has to come from.
| Rule | Attack path it closes | Still possible |
|---|---|---|
| Require a pull request | A stolen credential pushing straight to main | The same credential opening a PR and, if it can self-approve, merging it |
| Require approvals | One compromised account landing a change alone | Two colluding accounts, or one account approving without reading |
| Dismiss stale approvals | Approving a small diff, then pushing a large one before merge | A reviewer re-approving without re-reading |
| Require review from Code Owners | A change to a sensitive path being reviewed by anyone available | The code owner approving under time pressure |
| Require status checks | Merging with failing tests or a failing scan | Merging with passing checks that do not test the risk |
| Require branches up to date | Merging a change that was never tested against current main | Semantic conflicts that both branches’ tests pass |
| Require signed commits | Commits whose authorship cannot be verified | A legitimately signed commit containing a backdoor |
| Require linear history | Merge commits obscuring what was reviewed | The same content arriving as a rebased commit |
| Block force pushes | Rewriting history under everyone who has pulled | A new commit that reverts or replaces the same content |
| Restrict deletions | The branch being removed | The branch being emptied by an ordinary commit |
| Require deployments to succeed | Merging code that has not run in a staging environment | Staging differing from production |
| Lock branch | Any change at all, for an archived or frozen ref | Nothing — this is the strongest, and the least useful for active work |
Two rows deserve emphasis because they are the ones most often over-read.
Require signed commits is an authenticity control, not a quality control. It answers “can we verify who authored this?” and nothing else. A compromised developer machine signs malicious commits with the developer’s key, and every signature verifies.
Require status checks is only as strong as the checks. A repository requiring a green CI run where CI runs a linter has a required check that would not notice a credential exfiltration step added to a workflow file.
The self-approval question
Section titled “The self-approval question”The most common misconfiguration in this area is subtle enough to survive an audit.
“Require a pull request before merging” with zero required approvals is a real and useful setting — it forces changes through a reviewable surface and lets CI run. But on its own it stops nobody: the author opens the pull request and merges it immediately.
That is entirely reasonable for a solo project. It is not a control against a compromised account, because a compromised account can do both halves.
Requiring at least one approval changes the threat model materially: an attacker now needs either a second identity or a human who will approve without reading. Both happen, and both are harder than one stolen token.
Bypass is the control’s real strength
Section titled “Bypass is the control’s real strength”Every enforcement mechanism has an escape hatch, because production breaks at inconvenient times. The security question is never whether one exists — it is who holds it, whether use is visible, and whether anybody reads that record.
Historically, the “Do not allow bypassing the above settings” checkbox determined whether repository administrators and organisation owners were subject to the rules they configured. Left unchecked, an admin can push directly to a protected branch and the rule does not apply. Rulesets express the same idea more explicitly, as a bypass list of specific actors.
Either way, this is the number that determines the rule’s strength:
A protection rule is exactly as strong as the smallest set of identities that can step around it.
A repository with impeccable protection rules and eleven administrators has eleven unprotected paths to the default branch. An attacker does not break the rule; they compromise an account that does not have to obey it.
This is also why “admins can bypass” is not equivalent to “we trust our admins”. Trust in the person is not the relevant variable — the relevant variable is what happens when their laptop, session or token is someone else’s.
Making bypass acceptable
Section titled “Making bypass acceptable”Bypass can be made survivable rather than eliminated:
- Keep the list short and named. Specific accounts or a small team, not a role that grows.
- Prefer a break-glass identity that is not somebody’s daily account, so use is conspicuous.
- Make use visible. GitHub records bypasses in the organisation audit log; route that record somewhere a human sees it.
- Review after the fact. A bypass without a follow-up pull request explaining it is an unreviewed change to your default branch.
What branch protection does not touch
Section titled “What branch protection does not touch”This is the part that matters most for threat modelling, because these are the paths people assume are covered.
Tags. Classic branch protection covers branches. A release tag is not a branch, and consumers pin to tags. Tag protection requires rulesets or the older tag protection rules.
Other branches. A rule matching main says nothing about release/* or develop. Long-lived
branches that get deployed need their own coverage, and pattern matching is where this quietly fails.
Repository settings. Anyone who can change the rules does not need to break them. Admin access is the bypass that requires no bypass list entry.
Workflow files. A pull request that modifies .github/workflows/ is a pull request that changes
what runs with the repository’s credentials. Branch protection treats it as an ordinary file change
unless CODEOWNERS says otherwise — and a CODEOWNERS entry for .github/ is one of the highest-value
lines you can add.
Forks. A fork is a separate repository with its own rules, or none.
The content of the change. Protection governs process, not substance. Everything about whether the merged code is safe comes from review and from checks.
Three threat scenarios, walked through
Section titled “Three threat scenarios, walked through”Abstract rules become concrete when you trace an actual sequence.
Scenario 1 — a stolen personal access token
Section titled “Scenario 1 — a stolen personal access token”An engineer’s laptop is compromised and a token with repo scope is taken. The attacker wants a
backdoor in the next release.
Without protection: git push to main. Done in one command. The next build picks it up.
With a pull request requirement and one approval: the attacker must open a pull request, which creates a visible artefact with a diff, and must obtain an approval from a different account. The realistic path becomes social — a plausible-looking small change, submitted at a busy moment, hoping for a fast approval. That is a materially harder attack with a much higher chance of being noticed.
What still fails: if the token belongs to someone on the bypass list, none of the above applies. And if the “approval” comes from a machine account the attacker also controls, the count is satisfied.
Scenario 2 — a malicious workflow change
Section titled “Scenario 2 — a malicious workflow change”The attacker does not want to change application code at all. They want to add a step to
.github/workflows/ci.yml that exfiltrates repository secrets.
Why this is attractive: it runs with the repository’s credentials, it executes on every push, and workflow diffs get less scrutiny than application diffs because reviewers read them as configuration.
What branch protection does: nothing specific. It is a file change like any other, and it satisfies required approvals like any other.
What actually helps: a CODEOWNERS entry for /.github/ so the change routes to a named owner; a
read-only default GITHUB_TOKEN so the credential in scope is small; and the awareness that a
workflow diff is a privilege change. This is the clearest case where branch protection alone produces
false confidence.
Scenario 3 — the quiet force push
Section titled “Scenario 3 — the quiet force push”Someone rewrites main to drop a commit — perhaps innocently, cleaning up a mistake.
What happens without Block force pushes: everyone who has already pulled now has a divergent
history. The next person to push cleanly re-introduces the dropped commit without noticing, or worse,
somebody force-pushes back. The audit trail of what was on main is gone.
Security relevance: if the dropped commit was a security fix, the fix silently disappears from the branch while the pull request that added it still shows as merged.
What actually helps: blocking force pushes. This is the cheapest rule in the entire lesson and the one with the least workflow cost, because nobody legitimately force-pushes a shared branch.
Required status checks, more carefully
Section titled “Required status checks, more carefully”Status checks are where branch protection meets everything else in this pillar, and there are three failure modes worth knowing.
A check that never reports blocks forever. Requiring a check name that no workflow produces — because a job was renamed, or the workflow only runs on certain paths — leaves pull requests pending indefinitely with no error. This is the single most common support question about protected branches, and its security consequence is that people respond by removing required checks.
A check that is required is a check that must not be trivially skippable. If a workflow uses
paths: filters so the security scan only runs when certain files change, a pull request that
touches nothing in those paths never produces the check. Requiring it then reintroduces the previous
problem; not requiring it means the gate is optional. GitHub Actions’ “required workflows” and
ruleset-level check requirements exist to work around this, and the general answer is to make the job
run always and exit early, so the check reports success rather than not reporting.
Sharded checks change names. A test suite split into four parallel shards produces four check names. Change the shard count and every required-check name changes, silently disabling the requirement. Wrap sharded jobs in a single aggregating job and require that one.
The security framing: a required check that can silently stop reporting is a gate that can be removed without changing any setting.
Interaction with merge queues
Section titled “Interaction with merge queues”If your repository uses a merge queue, the timing of enforcement changes in a way that matters.
Without a queue, required checks run on the pull request branch — typically a merge of your branch
with main at the moment CI started. Between that moment and the merge, main can move.
With a queue, changes are tested in the exact combination and order in which they will land. That
closes a real gap: two independently safe changes that are unsafe together. The classic version is
one pull request adding a call site and another removing the function; both pass, and main breaks.
The security version is one pull request relaxing a check and another relying on it.
The queue’s own configuration then becomes part of the boundary. If it merges on a passing subset of checks, that subset is your real gate.
Protecting release branches
Section titled “Protecting release branches”Release branches are frequently deployed directly to production and frequently protected less than
main, because they are created later and rules are not retroactive.
A release/* pattern should generally be stricter than the default branch, not looser:
- Require a pull request, with approvals from a named group
- Require the same status checks as
main, plus anything release-specific - Block force pushes and restrict deletions
- Restrict who may create branches matching the pattern — otherwise anyone can create
release/anythingand inherit whatever automation watches that namespace
That last point is the one people miss. If a deployment workflow triggers on release/*, then the
ability to create a branch matching that pattern is the ability to trigger a deployment.
Branch protection or rulesets?
Section titled “Branch protection or rulesets?”Both mechanisms exist and both work. From a security standpoint, rulesets are the better instrument for three reasons:
They layer. Multiple rulesets apply together, and the result is the union of their restrictions. Classic branch protection picks one matching rule, so a broad rule and a specific one do not combine the way people expect.
They cover tags and pushes. Branch protection covers branches only.
They have an evaluation mode. A ruleset can be set to evaluate without enforcing, so you can see what it would have blocked before it blocks anything. That turns rollout across an organisation from a negotiation into a measurement.
Classic branch protection remains perfectly serviceable for a single repository with one protected branch. The moment you need tag rules, organisation-level policy, or layered exceptions, rulesets are the mechanism. Repository Rulesets for Security is next for exactly that reason.
Verifying protection actually holds
Section titled “Verifying protection actually holds”Configuration is a claim. These four checks produce evidence.
Direct push. From an account that should be constrained, git push to the protected branch. Read
the rejection and note which rule it names. If it succeeds, you have found a bypass entry you did not
know about.
Force push. git push --force. Same test, different rule.
Self-merge. Open a trivial pull request and try to merge it yourself. If it merges, required approvals are not doing what you think.
Tag move. Create a tag, push it, move it, force-push it. Most repositories fail this one.
Do these on a schedule, not once. Rules change; so do bypass lists.
Common mistakes
Section titled “Common mistakes”Requiring a pull request with zero approvals and calling it protected. It forces a reviewable surface and runs CI. It does not stop a single compromised account.
Leaving administrators exempt. Under an ordinary threat model this is convenient. Under the threat model where an account is compromised, it makes the rule optional for exactly the accounts most worth compromising.
Protecting main and nothing else. Deployment often runs from other refs. Protection has to
follow whatever actually reaches production.
Assuming a required check tests the risk. A green build says the build was green. Whether it tested the thing you are worried about is a separate question that nobody re-asks after the rule is configured.
No CODEOWNERS entry for .github/. Workflow changes are credential-scope changes and deserve a
named reviewer.
Treating branch protection as covering tags. It does not, and tags are what downstream consumers depend on.
Mental model
Section titled “Mental model”Branch protection is a policy on a ref, evaluated for an actor. It answers “may this identity do this to this branch?” — never “is this change safe?” and never “should this identity exist at all?”
What you learned
Section titled “What you learned”- Branch protection closes one class of path: an identity with write access modifying a protected ref outside the required process
- Requiring a pull request with zero approvals does not stop a single compromised account
- A rule’s real strength is the size of the set that can bypass it, including administrators
- Signed-commit requirements are authenticity controls and say nothing about content
- Required status checks are exactly as strong as the checks themselves
- Branch protection does not cover tags, other branches, repository settings or forks
- Release branches usually need stricter rules than the default branch, including creation restrictions
- Rulesets layer, cover tags and pushes, and support evaluation mode; branch protection does none of these
- Verification means attempting the forbidden action, on a schedule
Exercise
Section titled “Exercise”Use a disposable repository. Predict each outcome first.
-
Protect the default branch requiring a pull request with zero approvals. Predict: can you open a pull request and merge it yourself? Try it.
-
Raise the requirement to one approval. Try again. Note the precise wording of the block.
-
Check whether your account can bypass. If it can, bypass and observe that no rule fired.
-
Add a required status check that does not exist yet. Predict: what happens to a pull request? Observe that it waits indefinitely — a check that never reports is a merge that never happens.
-
Create and push
v1.0.0, then move it withgit tag -fandgit push --force origin v1.0.0. Predict: does branch protection stop this? -
Add a
CODEOWNERSentry for/.github/naming yourself, and enable required Code Owner review. Open a pull request changing a workflow file and observe the reviewer requirement change. -
Delete the repository.
Related lessons
Section titled “Related lessons”Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.