Skip to content

Secure Git Repository Configuration

Lesson 1 of 9Intermediate14 min readGit Security & DevSecOps · Repository SecurityVerified: git 2.43.0 on Ubuntu 24.04; GitHub repository settings and rulesets, September 2026

Most repositories are configured once, by whoever created them, using whatever the defaults were that week. Nothing prompts a review, and nothing surfaces the gap between what the settings permit and what the team believes they permit.

This lesson is a baseline you can apply to a repository in about an hour, plus — more importantly — what each control actually protects, so you can tell which of them you are relying on.

A repository with a defensible baseline has:

  • Access limited to people and machines that need it, granted through teams rather than individually, reviewed on a schedule.
  • The default branch protected — pull request required, force pushes blocked, deletion restricted.
  • Release tags protected so a published version cannot be moved.
  • No long-lived credentials in the repository, in its Actions secrets, or in any deploy key that outlives its purpose.
  • GITHUB_TOKEN read-only by default, with workflows widening it per job.
  • A .gitignore that covers the local files that generate secrets — environment files, key material, build state.
  • Secret scanning and push protection enabled, which is free and default on public repositories.
  • A stated bypass list that somebody chose, rather than one that accumulated.

Everything below is why, and what remains uncovered.

Start with the threat model, not the checklist

Section titled “Start with the threat model, not the checklist”

A checklist applied without a threat model produces the characteristic failure of repository hardening: heavy protection on the paths nobody uses, and none on the one that matters.

Threat. An unauthorised change reaches the default branch or a release tag, and is then built and deployed.

Attack surface. Every identity with write access — human accounts, their tokens, their SSH keys, their sessions; and machine identities: deploy keys, GitHub Apps, Actions workflows, and any integration somebody connected two years ago. Plus repository settings themselves, since anyone who can change the rules does not need to break them.

Impact. In a repository with CI, write access is code execution with the CI system’s credentials. In a repository that deploys, that is production access, regardless of what the pull request said.

Control. Least privilege on identities, enforcement on refs, verifiable authorship, and no standing credentials.

Verification. Try it. Push directly to main. Move a release tag. Use a token you believe is scoped to one repository against a second one.

That last row is the one that finds real problems. Reading a settings page confirms configuration; attempting the action confirms behaviour.

Public and private are not a security boundary

Section titled “Public and private are not a security boundary”

The most consequential assumption people make about a repository is that private means safe.

Private controls who can read. That set is larger and more mutable than it feels: every collaborator, every member of every team with access, every organisation owner, every automation token scoped to the repository, every fork taken while access existed, and every clone on every laptop and CI runner that has ever fetched it.

It also does not shrink retroactively. Someone who left the company still has whatever they cloned.

Some of the baseline is Git rather than GitHub, and it applies to every clone.

The remote determines how you authenticate every time you push.

Terminal window
git remote -v

What it doesPrints the fetch and push URLs configured for each remote.

Why we run itAn HTTPS remote authenticates with a token; an SSH remote authenticates with a key. Which one you have determines which credential a compromise exposes.

Expected resultOne or more lines per remote, showing https://github.com/… or git@github.com:….

Neither is inherently more secure. HTTPS with a credential helper backed by an OS keychain and a short-lived token is good. SSH with a passphrase-protected key in an agent is good. HTTPS with a non-expiring personal access token stored in a plaintext file is not, and it is the most common configuration in practice. Git Credentials covers the storage question in full.

A URL with a credential embedded in it — https://USERNAME:YOUR_TOKEN@github.com/OWNER/REPO.git — writes that token into .git/config in cleartext, where it survives, gets copied with backups, and appears in any git remote -v output anyone screenshots.

Terminal window
git config --list --show-origin

What it doesLists every configuration value together with the file it came from.

Why we run itRepository-local configuration overrides your global settings, and a cloned repository can arrive with values you did not choose.

Expected resultLines of the form file:.git/config user.email=….

Three values are worth checking explicitly:

SettingWhy it matters
user.emailDetermines commit attribution, and whether GitHub links commits to your account
core.hooksPathIf set, hooks run from a directory you may not have inspected
credential.helperDetermines where your credentials are stored and how well

core.hooksPath deserves a moment. Git does not execute hooks from a cloned repository’s .git/hooks by default — that is a deliberate safety property, since a repository could otherwise run code on clone. But a repository can ship a .githooks/ directory and a README instructing you to run git config core.hooksPath .githooks, and that instruction is exactly as dangerous as running any other script from a repository you have not read.

.gitignore is a security control in the narrow sense that it prevents accidental staging of files that habitually contain credentials.

.gitignore
{/* Environment and local configuration */}
.env
.env.*
!.env.example
*.local
{/* Key material */}
*.pem
*.key
*.p12
*.pfx
id_rsa
id_ed25519
{/* Cloud and tooling state */}
.terraform/
*.tfstate
*.tfstate.*
.aws/
credentials
{/* Editor and OS noise that has leaked secrets before */}
.vscode/settings.json
.idea/
.DS_Store

The !.env.example line is the part people leave out. A negated pattern keeps the template tracked while its real counterpart stays ignored, which gives new contributors the shape of the configuration without any values.

Grant access through teams, not to individuals. Individual grants are invisible in aggregate: nobody can answer “who can write to this repository” without opening every repository, and offboarding becomes a search problem.

The roles, in ascending order of what they permit:

RoleCan
ReadClone, open issues and pull requests
Triage…plus manage issues and pull requests without write access
Write…plus push branches, create tags, edit workflow files
Maintain…plus manage some settings, without destructive access
Admin…plus change settings, rules, access and delete the repository

Two of these get handed out too readily. Write includes editing .github/workflows/, which means changing what runs with the repository’s credentials — in most repositories the most powerful thing write access confers, and it is invisible in the role name.

Admin includes changing the rules that constrain everyone else. An admin does not need to break a protection rule; they can amend it, do the thing, and put it back. That is why admin count is a better security metric than protection rule count.

Protect it. This is the highest-value change in the lesson and it takes three settings.

  • Require a pull request before merging. Closes direct pushes, which is the path a compromised credential takes.
  • Block force pushes. Prevents history being rewritten under everyone who has already pulled.
  • Restrict deletions. Prevents the branch disappearing.

Add required status checks when you have checks worth requiring, and required reviews when you have reviewers. Branch Protection for Security covers the security framing; Repository Rulesets for Security covers the newer and more capable mechanism.

Consistently skipped, and consistently the thing consumers actually depend on.

Downstream users pin to v2.1.0, not to a branch. If that tag can be moved, the code somebody installed last month is not necessarily the code the tag resolves to now — and nothing in their tooling will tell them.

A tag ruleset that restricts updates and deletions on v* closes this. So, more strongly, does making releases immutable.

Two settings, both under the repository’s Actions configuration, both worth changing from their defaults in most repositories:

Workflow permissions. Set the default GITHUB_TOKEN to read-only. Workflows that need to write then declare it explicitly per job, which makes the grant visible in review:

permissions:
contents: read
jobs:
publish:
permissions:
contents: write
packages: write

Allowed actions. Restrict which actions may run — GitHub-authored, verified creators, and a specific allowlist. This bounds what third-party code can execute in a job that holds your credentials. Pinning actions covers the version half of the same problem.

Free and enabled by default on public repositories. On private repositories they require GitHub Secret Protection, purchasable on Team and Enterprise plans.

Enable both wherever you can. They are the only controls in this lesson that stop a credential before it becomes an incident.

Merge settings, which are security settings

Section titled “Merge settings, which are security settings”

The merge options are usually treated as workflow preference. Two of them have security consequences.

Allowed merge methods. Squash merging collapses a branch into a single commit, so the individual commits — including their signatures — do not appear on the default branch. The three methods differ in a way that matters if you require signed commits: a commit GitHub creates through the web interface is signed with GitHub’s own key, whereas rebase and merge adds the head branch’s commits to the base branch without signature verification. Requiring signed commits while allowing rebase and merge is a combination that will surprise somebody, and Signed Git Commits covers what to do about it.

Automatically delete head branches. Reduces the number of stale branches carrying old code and old credentials in old CI configuration. Low value individually, meaningful in a repository with years of abandoned branches.

Fork policy. In an organisation, whether private repositories may be forked at all is a policy decision. A fork is a full copy under someone else’s control, and it does not disappear when their access does.

Wikis, Discussions, Projects and Issues each accept content, and content is where credentials get pasted. Secret scanning covers issues, pull requests, discussions and wikis on repositories where it is enabled — but a wiki nobody uses and nobody reads is an unmonitored surface.

Turn off what the repository does not use. It is the cheapest possible reduction in attack surface, and it also removes places where a stale answer can mislead somebody two years from now.

Git’s author field is local configuration. Any clone can set user.name and user.email to anything, and the resulting commit looks entirely ordinary.

Terminal window
git -c user.name="Some Maintainer" -c user.email="maintainer@example.com" commit -m "Fix"

That commit will show that name in git log and, if the email matches a GitHub account, that account’s avatar in the web interface. Nothing about it is a lie Git can detect, because Git never claimed the field was authenticated.

Signing closes the gap: a signed commit carries cryptographic evidence linking it to a key, and GitHub shows Verified when it can match that key to an account. Requiring signed commits on a protected branch makes unverifiable authorship un-mergeable.

What signing does not do is say anything about the content. A signed commit is a commit whose origin you can check. Signed Git Commits is the full treatment.

Deploy keys. Repository-scoped SSH keys, frequently added for a one-off deployment and never removed. A read-write deploy key is push access that belongs to no person, appears in no team, and is not covered by anyone’s offboarding.

Forks. A fork made while a repository was accessible keeps whatever it had. Making the upstream private later does not reach it.

Webhooks. They carry repository events to somewhere else, with a signing secret you should be verifying at the receiving end. A stale webhook is an ongoing data flow to a system nobody owns any more. See GitHub webhooks.

Integrations and Apps. Every installed GitHub App holds permissions somebody approved once. Read the list; the surprises are in it.

Environment secrets. Credentials attached to deployment environments, which — unlike repository secrets — can require reviewers before a job may read them. If production credentials live anywhere, they should live there. See Environments.

Both are supported, both can be configured well or badly, and the decision is usually made by whichever the person setting up the machine already had working. It is worth making deliberately.

SSHHTTPS + token
CredentialPrivate key file, ideally passphrase-protected in an agentToken stored by a credential helper
ExpiryKeys do not expire unless you rotate themFine-grained tokens can be given a maximum lifetime
ScopeThe key is tied to an account, or to one repository as a deploy keyFine-grained tokens are scoped per repository and per permission
RevocationRemove the key from the accountRevoke the token
Blocked networksPort 22 is sometimes blocked; port 443 fallback existsWorks anywhere HTTPS works
Common failurePassphrase-free key sitting in ~/.sshNon-expiring token in a plaintext file

The security-relevant differences are expiry and scope, and they favour fine-grained tokens: an SSH key on an account carries that account’s access to everything, indefinitely.

Against that, an SSH key with a passphrase held in an agent is not readable from disk by a process that finds the file, whereas most credential helpers hand over the token on request to anything running as you.

The pragmatic answer for most people: SSH with a passphrase for interactive use, fine-grained tokens for anything automated, and nothing long-lived in CI at all. Git Credentials and Git SSH Keys each go a level deeper.

Repository security includes availability, and Git’s distributed model creates a comfortable illusion here. Every clone contains the full commit history, so the code is genuinely resilient.

What is not in a clone: issues, pull requests and their review history, Actions run logs and artifacts, releases and their assets, wikis, settings, rulesets, and the access model itself.

If a repository is deleted or an account is compromised, the code survives in clones and everything that explains the code does not. Anything you would need after an incident — the discussion that justified a design, the approval record for a release — needs an export, not a clone.

Configuration decays. Somebody is granted admin to unblock a release. A deploy key is added for a migration. A bypass entry is added “temporarily”.

A useful audit answers four questions, in this order:

  1. Who and what can write? Include machine identities. This is where accumulated risk lives.
  2. Who can change the rules? The bypass list plus the admin list. If it is long, the rules are advisory.
  3. What credentials exist, and when do they expire? Actions secrets, deploy keys, App installations, personal access tokens with access to the repository.
  4. Does the protection actually hold? Attempt a direct push to the protected branch from an account that should not be able to.

Question 4 is the one that finds problems the other three miss, because the first three read configuration and the fourth tests behaviour.

For organisations, GitHub’s audit log records administrative actions including rule bypasses. That record is only a control if somebody reads it; an unread audit log is an archive.

Believing .gitignore protects a tracked file. It governs untracked files only. Adding a path after the file is committed changes nothing about the file or its history.

Protecting main and leaving tags open. Consumers pin to tags. A movable tag means a published version is not fixed.

Leaving GITHUB_TOKEN at write by default. Every job then holds write access to the repository, including jobs that only run tests, and including any third-party action inside them.

Granting admin to unblock something and leaving it. Admin is not a temporary state unless somebody removes it, and nothing prompts them to.

Treating a private repository as a safe place for a credential. It is a moderately wide distribution list with a long memory.

Auditing settings rather than outcomes. The configuration you can read and the behaviour you get diverge more often than anyone expects, usually via a bypass list.

Repository security is the intersection of who can act and what a change must survive before it lands. Identity controls without ref controls means anyone trusted can do anything; ref controls without identity controls means the rules apply only to people who are not holding an admin token.

  • Private repositories restrict readership; they are not a boundary that makes a committed credential acceptable
  • .gitignore prevents tracking, and does nothing to a file already tracked or already in history
  • Write access includes editing workflow files, which is code execution with the repository’s credentials
  • Protecting the default branch takes three settings; protecting tags is the step usually missed
  • GITHUB_TOKEN should default to read-only, with workflows widening it per job
  • Git’s author field is a claim, and only a signature makes it verifiable
  • Deploy keys, webhooks, App installations and forks are the identities and flows audits overlook
  • Clones preserve code, not issues, reviews, releases, settings or access
  • Verification means attempting the action the control forbids, not reading the settings page

Use a disposable repository. Predict each outcome before running the command.

  1. Create a private repository and clone it. Add a file called .env containing API_KEY=EXAMPLE_SECRET_VALUE, commit and push.

  2. Now add .env to .gitignore, commit and push. Predict: is .env still tracked? Run git ls-files | grep env and check.

  3. Run git rm --cached .env and commit. Predict: does the earlier commit still contain the value? Run git log --all -p -- .env and check.

  4. Protect the default branch with a pull request requirement. Attempt git push directly to it. Read the rejection message carefully — note which rule it names.

  5. Check whether your own account can bypass that rule. If it can, you have measured the rule’s real strength.

  6. Create a tag v1.0.0 and push it. Move it to a different commit with git tag -f v1.0.0 && git push --force origin v1.0.0. Predict: does it succeed? Add a tag ruleset restricting updates on v* and try again.

  7. Delete the repository when you are finished.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.