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.
The short answer
Section titled “The short answer”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_TOKENread-only by default, with workflows widening it per job.- A
.gitignorethat 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.
Repository configuration on the Git side
Section titled “Repository configuration on the Git side”Some of the baseline is Git rather than GitHub, and it applies to every clone.
Remote URLs
Section titled “Remote URLs”The remote determines how you authenticate every time you push.
git remote -vWhat 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.
Configuration that affects safety
Section titled “Configuration that affects safety”git config --list --show-originWhat 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:
| Setting | Why it matters |
|---|---|
user.email | Determines commit attribution, and whether GitHub links commits to your account |
core.hooksPath | If set, hooks run from a directory you may not have inspected |
credential.helper | Determines 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.
Ignored files
Section titled “Ignored files”.gitignore is a security control in the narrow sense that it prevents accidental staging of files
that habitually contain credentials.
{/* Environment and local configuration */}.env.env.*!.env.example*.local
{/* Key material */}*.pem*.key*.p12*.pfxid_rsaid_ed25519
{/* Cloud and tooling state */}.terraform/*.tfstate*.tfstate.*.aws/credentials
{/* Editor and OS noise that has leaked secrets before */}.vscode/settings.json.idea/.DS_StoreThe !.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.
Repository settings on the GitHub side
Section titled “Repository settings on the GitHub side”Access
Section titled “Access”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:
| Role | Can |
|---|---|
| Read | Clone, 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.
The default branch
Section titled “The default branch”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.
Actions permissions
Section titled “Actions permissions”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: writeAllowed 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.
Secret scanning and push protection
Section titled “Secret scanning and push protection”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.
Features you are not using
Section titled “Features you are not using”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.
Commit authenticity
Section titled “Commit authenticity”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.
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.
The parts people forget
Section titled “The parts people forget”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.
Choosing between SSH and HTTPS
Section titled “Choosing between SSH and HTTPS”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.
| SSH | HTTPS + token | |
|---|---|---|
| Credential | Private key file, ideally passphrase-protected in an agent | Token stored by a credential helper |
| Expiry | Keys do not expire unless you rotate them | Fine-grained tokens can be given a maximum lifetime |
| Scope | The key is tied to an account, or to one repository as a deploy key | Fine-grained tokens are scoped per repository and per permission |
| Revocation | Remove the key from the account | Revoke the token |
| Blocked networks | Port 22 is sometimes blocked; port 443 fallback exists | Works anywhere HTTPS works |
| Common failure | Passphrase-free key sitting in ~/.ssh | Non-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.
Backup and recovery, briefly
Section titled “Backup and recovery, briefly”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.
Auditing what you configured
Section titled “Auditing what you configured”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:
- Who and what can write? Include machine identities. This is where accumulated risk lives.
- Who can change the rules? The bypass list plus the admin list. If it is long, the rules are advisory.
- What credentials exist, and when do they expire? Actions secrets, deploy keys, App installations, personal access tokens with access to the repository.
- 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.
Common mistakes
Section titled “Common mistakes”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.
Mental model
Section titled “Mental model”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.
What you learned
Section titled “What you learned”- Private repositories restrict readership; they are not a boundary that makes a committed credential acceptable
.gitignoreprevents 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_TOKENshould 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
Exercise
Section titled “Exercise”Use a disposable repository. Predict each outcome before running the command.
-
Create a private repository and clone it. Add a file called
.envcontainingAPI_KEY=EXAMPLE_SECRET_VALUE, commit and push. -
Now add
.envto.gitignore, commit and push. Predict: is.envstill tracked? Rungit ls-files | grep envand check. -
Run
git rm --cached .envand commit. Predict: does the earlier commit still contain the value? Rungit log --all -p -- .envand check. -
Protect the default branch with a pull request requirement. Attempt
git pushdirectly to it. Read the rejection message carefully — note which rule it names. -
Check whether your own account can bypass that rule. If it can, you have measured the rule’s real strength.
-
Create a tag
v1.0.0and push it. Move it to a different commit withgit tag -f v1.0.0 && git push --force origin v1.0.0. Predict: does it succeed? Add a tag ruleset restricting updates onv*and try again. -
Delete the repository when you are finished.
Related lessons
Section titled “Related lessons”Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.