What never belongs in Git

A repository is copied in full to every clone, every fork, every CI (continuous integration) runner and every backup. Anything in it is effectively shared with everyone who will ever have read access, now or later, including after a repository is accidentally made public. So these stay out:

  • passwords and connection strings with passwords in them;
  • API (application programming interface) keys and access tokens for clouds, monitoring, ticketing or a network source of truth;
  • private keys: SSH (Secure Shell) keys, TLS (Transport Layer Security) certificate keys, signing keys;
  • .env files and local configuration files that hold any of the above;
  • device configurations with secrets in them (SNMP (Simple Network Management Protocol) communities, pre-shared keys, enable secrets): template them and fill the secrets in at deploy time.

Instead, code reads secrets at run time from an environment variable, a secrets manager or, in a pipeline, the platform's encrypted secret store (see GitHub Actions). A .gitignore entry makes sure local secret files are never staged by accident:

git init -q tools && cd tools
printf '.env\n*.key\n' > .gitignore
printf 'NETBOX_TOKEN=EXAMPLE-0000-not-a-real-token\n' > .env
printf 'import os\n\ntoken = os.environ["NETBOX_TOKEN"]\n' > sync.py
git add .
git status --short
git check-ignore -v .env
A  .gitignore
A  sync.py
.gitignore:1:.env	.env

git add . staged the code and the ignore file but not .env; git check-ignore -v shows which line of which file is responsible. Two limits: an ignore rule does nothing for a file that is already tracked (remove it from the index with git rm --cached .env and commit), and git add -f overrides it.

A local safety net

A Git hook is a script in .git/hooks/ that Git runs at certain moments. A pre-commit hook that exits with an error stops the commit. This small one refuses staged lines that look like a private key or an API token:

git init -q tools && cd tools
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
# Refuse the commit if a staged change adds something that looks like a secret
if git diff --cached -U0 | grep -E '^\+.*(PRIVATE KEY|api_token *=)'; then
    echo "pre-commit: possible secret in staged changes, commit refused" >&2
    exit 1
fi
HOOK
chmod +x .git/hooks/pre-commit

printf '[netbox]\napi_token = EXAMPLE-0000-not-a-real-token\n' > config.ini
git add config.ini
git commit -q -m "Add NetBox config"
+api_token = EXAMPLE-0000-not-a-real-token
pre-commit: possible secret in staged changes, commit refused

Hooks help, but they are not a control you can rely on: they are not copied by git clone, each person must install them, and git commit --no-verify skips them. Teams share hooks through tools such as the pre-commit framework with a dedicated secret detector, and back them up with push protection on the server, which nobody can skip by accident.

History never forgets

The most common misunderstanding about leaked secrets: "I removed it in the next commit, so it's gone." It is not. A commit is a snapshot of the whole project, and the old snapshot is still in the repository (see How Git stores your work).

git init -q tools && cd tools
cat > config.ini <<'INI'
[netbox]
url = https://netbox.example.com
api_token = EXAMPLE-0000-not-a-real-token
INI
git add config.ini && git commit -q -m "Add NetBox config"

# Oops. Delete the token line and commit again
sed -i '/api_token/d' config.ini
git commit -q -am "Remove token from config"

cat config.ini
echo "--- the previous version is still in the repository:"
git show HEAD~1:config.ini
echo "--- every commit that added or removed the token:"
git log -S EXAMPLE-0000 --oneline
[netbox]
url = https://netbox.example.com
--- the previous version is still in the repository:
[netbox]
url = https://netbox.example.com
api_token = EXAMPLE-0000-not-a-real-token
--- every commit that added or removed the token:
4031a05 Remove token from config
8eea74f Add NetBox config

git show HEAD~1:config.ini prints the file as it was one commit ago, token included. git log -S (the "pickaxe") finds every commit that changed how often a string occurs, which is exactly how an attacker with a clone would search for secrets. Once this history is pushed, every clone and fork has it too.

When a secret has leaked

Once a secret has reached a shared remote, assume it is compromised. Automated scanners watch public pushes and can find a token within minutes. The order of work matters:

  1. Rotate first. Revoke the token, change the password, replace the key at the service that issued it, and update the places that legitimately use it. This is the only step that actually makes the leaked value worthless.
  2. Check for misuse. Look at the service's audit logs for activity with the leaked credential between the push and the rotation. Treat any hit as a security incident (see Incident response).
  3. Clean the history, if the repository's policy or the sensitivity of the data requires it. Removing the secret from history prevents new copies; it cannot recall the copies that already exist.
  4. Prevent the next one: a .gitignore entry, a secrets manager, push protection.

Cleaning history means rewriting every commit that contains the secret. The tool the Git project recommends is git filter-repo (a separate download); the older BFG Repo-Cleaner does similar work. Neither is part of a standard Git installation, so the commands are shown, not run:

# Work in a fresh mirror clone, never in your everyday working copy
git clone --mirror https://github.com/example-corp/netconf.git
cd netconf.git

# Replace the leaked value everywhere in history with ***REMOVED***
echo 'EXAMPLE-0000-not-a-real-token' > ../secrets.txt
git filter-repo --replace-text ../secrets.txt

# Or remove a whole file from every commit
git filter-repo --invert-paths --path config.ini

# filter-repo removes the origin remote as a safety measure: add it back,
# then overwrite all branches and tags on the remote
git remote add origin https://github.com/example-corp/netconf.git
git push --force --all origin
git push --force --tags origin

Secret scanning and push protection

Secret scanning is a GitHub feature that searches the contents and full history of a repository for known secret formats, such as cloud provider keys and tokens from many services, and raises an alert when it finds one. For many providers GitHub also notifies the issuer, which may revoke a public leak automatically. Alerts appear in the repository's Security tab.

Push protection

Scanning after the fact still means rotating. Better to stop the secret at the door: push protection checks every push for supported secrets and rejects the push if it finds one, before the commit becomes part of the remote repository. The person pushing sees which file and line triggered it, and can remove the secret and push again. They can also bypass the block by giving a reason (for example a false positive or a test value), which is recorded and can alert the repository's administrators.

Availability, as of 2026:

Repository Secret scanning Push protection
Public, any plan Automatic and free "Push protection for users" is on by default for your pushes to public repositories
Private or internal, owned by an organization Requires GitHub Secret Protection, on the Team or Enterprise Cloud plan

Since 1 April 2025, GitHub sells its former GitHub Advanced Security as two separate products, both available to Team plans for the first time: GitHub Secret Protection (secret scanning, push protection, AI-based password detection, custom patterns; announced at USD 19 per active committer per month) and GitHub Code Security (code scanning with CodeQL, Copilot Autofix, dependency review; announced at USD 30 per active committer per month). Prices and packaging change; check GitHub's pricing page for the current situation.

Dependabot: your dependencies

Most of the code you run was written by someone else. A known vulnerability in a library you depend on is a vulnerability in your tool. Dependabot is GitHub's built-in dependency tool: it compares the dependencies in your manifest and lock files with GitHub's advisory database and alerts you about vulnerable versions, and it can open pull requests that update them. It has three parts:

Part What it does How to turn it on
Dependabot alerts Alerts when a dependency has a known vulnerability, with severity and the fixed version Repository security settings
Dependabot security updates Opens a pull request that raises a vulnerable dependency to the minimum fixed version Repository security settings (needs alerts)
Dependabot version updates Opens pull requests on a schedule to keep dependencies current, vulnerable or not A .github/dependabot.yml file

All three are available for repositories on GitHub.com. A version update configuration for a Python tool that also keeps its workflow actions current:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "pip"            # requirements.txt, pyproject.toml
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5

  - package-ecosystem: "github-actions" # uses: lines in .github/workflows
    directory: "/"
    schedule:
      interval: "weekly"

Dependabot's pull requests go through the same checks and reviews as anyone else's. That is the point: the pipeline tells you whether the update breaks anything. Merging them promptly keeps each one small. Dependency risk is one form of supply chain attack; pipelines that check dependencies on every change are covered in Testing and quality in pipelines.

Software bill of materials

A software bill of materials (SBOM) is a machine-readable list of every component in a piece of software (its direct and indirect dependencies with names, versions and licenses), so that when a new vulnerability is announced you can tell quickly whether you are affected. Customers and regulations increasingly ask suppliers for one. GitHub builds a dependency graph of each repository from its manifest and lock files (Insights → Dependency graph), and can export it as an SBOM in the SPDX (Software Package Data Exchange) format, from that page or through the API:

gh api repos/example-corp/netconf/dependency-graph/sbom > netconf-sbom.json

Code scanning with CodeQL

Dependabot looks at other people's code; code scanning looks at yours. Code scanning is a GitHub feature that analyzes a repository's source code for security vulnerabilities and coding errors and shows the results as alerts on the Security tab and as annotations on pull requests. It accepts results from many analysis tools; the built-in one is CodeQL, GitHub's analysis engine, which turns source code into a database and runs queries over it to find patterns such as untrusted input reaching a SQL (Structured Query Language) query or a shell command.

Typical findings: SQL injection, command injection, path traversal, cross-site scripting, hard-coded credentials, unsafe deserialization, and in workflow files, script injection (see GitHub Actions). You can turn it on with a default setup that GitHub manages, or with an advanced setup: a workflow file you control. Code scanning is free on public repositories; private organization repositories need GitHub Code Security. Where code scanning fits among other pipeline checks is on Testing and quality in pipelines.

Signed commits

The author name on a commit is just text: anyone can run git config user.name "Mei Chen" and commit as Mei. A signed commit carries a digital signature made with the committer's private key, so a platform that knows the matching public key can verify who created it and that it was not changed afterward. GitHub supports GPG (GNU Privacy Guard), SSH and S/MIME (Secure/Multipurpose Internet Mail Extensions) signatures and marks verified commits with a "Verified" badge. SSH signing is the simplest for most people, because the SSH key you already use to push can sign too (Git supports SSH signing since version 2.34):

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true    # sign every commit
git config --global tag.gpgsign true       # and every annotated tag

Then add the same public key on GitHub a second time, as a signing key (Settings → SSH and GPG keys); a key added only as an authentication key does not verify signatures. Commits you make in GitHub's web interface are signed by GitHub itself.

By default, an unsigned commit simply shows no badge, so a forged unsigned commit in your name looks like any other. With vigilant mode (off by default, in your account's SSH and GPG keys settings), GitHub labels all your commits and tags: unsigned commits that claim to be yours are marked "Unverified". A branch rule can also require signed commits. Note that "Rebase and merge" creates new commits that GitHub cannot verify as signed by the original author (see merge methods).

Tokens, keys and 2FA

Personal access tokens

A personal access token (PAT) is a generated secret that acts as your GitHub identity for the API and for Git over HTTPS (Hypertext Transfer Protocol Secure), in place of your password. There are two kinds:

Fine-grained Classic
Repositories Only the ones you select Every repository you can access
Permissions Per area, read or write (contents, issues, pull requests …) Broad scopes such as repo
Recommendation GitHub's recommended choice Only for the tasks that still need it

Apply least privilege: one token per purpose, only the repositories and permissions that purpose needs, and an expiry date. A token that can only read one repository for 30 days does far less damage when it leaks than a classic token that can write to everything forever. Inside GitHub Actions you rarely need a PAT at all: the automatic GITHUB_TOKEN covers the workflow's own repository.

SSH keys

For Git over SSH, GitHub's documentation recommends an Ed25519 key pair, protected with a passphrase:

ssh-keygen -t ed25519 -C "sam@example.com"

RSA keys are for legacy systems only. GitHub announced in September 2026 that new RSA keys must be at least 3072 bits from 14 October 2026, and that the old ssh-rsa signature algorithm (based on SHA-1, Secure Hash Algorithm 1) will be switched off on 13 January 2027, after short brownouts in November and December 2026. Existing RSA keys keep working with the newer RSA signature algorithms that current SSH clients use; very old clients may need an update. An Ed25519 key avoids the question entirely.

Two-factor authentication

Since March 2023, GitHub has required two-factor authentication (2FA) for accounts that contribute code on GitHub.com, rolled out group by group. A stolen password alone then no longer opens your account. Prefer a passkey or security key over codes sent by text message (SMS); see multi-factor authentication and passkeys. Organizations can require 2FA for all their members.

A checklist

Area Do this
Secrets Keep them out of the repository; .gitignore for .env and key files; secrets in a secrets manager or the CI secret store
Leaks Secret scanning and push protection on; a written plan: rotate, check logs, then clean history
Dependencies Dependabot alerts and security updates on; version updates via dependabot.yml, including github-actions; export an SBOM when asked
Your code Code scanning with CodeQL on every pull request
Changes Rulesets: pull requests with reviews, required checks, no force pushes to main; CODEOWNERS for sensitive paths (see protecting branches)
Identity 2FA (ideally passkeys), signed commits with vigilant mode, Ed25519 SSH keys
Tokens Fine-grained, per purpose, minimal permissions, with an expiry date; review and delete unused ones
Pipelines Read-only GITHUB_TOKEN by default, actions pinned to a commit SHA, OIDC instead of stored cloud keys (see GitHub Actions)