Keeping Sensitive Data Out of Git

Every commit you make in Git is permanent by design: not “hard to undo,” but immutable. That’s great for tracking history, and a serious liability the moment you accidentally add a password, an API key, a private SSH key, or a .env file to a commit. Once a secret is committed, it lives forever in the repository’s object database, and if you’ve pushed it to GitHub, automated bots scan public repositories for leaked credentials within seconds. This lesson explains why secrets leak into Git, how to stop it before it happens, and — when it’s too late — how to actually remove a secret from history rather than just hiding it.

Overview / How It Works

To understand why “delete the file and commit again” doesn’t remove a secret, you need Git’s object model. Every commit points to a tree object, a snapshot of your project’s directory structure at that moment. Each tree points to blob objects (raw file contents) and to other trees for subdirectories. None of these objects are ever modified in place — they’re identified by a SHA-1 hash of their content, so any change to a file produces a brand-new blob with a new hash, and the parent tree and commit get new hashes too. Nothing is overwritten; old objects simply stop being referenced by new commits, but they still exist in .git/objects until something explicitly garbage-collects them.

That means if you commit a file called .env containing a database password, then later delete the file and commit again, the newest commit’s tree no longer includes that blob — but the earlier commit still does, and that earlier commit remains reachable by walking your branch’s history backward. Anyone who clones the repository, runs git log -p, or checks out an old commit can still see the secret. Deleting a file only changes the current snapshot; it does nothing to snapshots already recorded.

The correct mental model: preventing a secret from ever being committed is cheap (.gitignore, environment variables, secret scanners). Removing a secret already in history is expensive — it requires rewriting every commit after the one that introduced it, which changes commit hashes and breaks any clone or fork that already has the old history. “Prevent, don’t clean up” is the guiding principle here.

What counts as sensitive data

API keys and tokens (AWS access keys, Stripe secret keys, GitHub personal access tokens), database connection strings and passwords, private SSH or GPG keys, .env files, TLS private keys, cloud service-account JSON files, and internal-only configuration that could aid an attacker all belong in this category. If a file’s contents would cause harm in the wrong hands, it doesn’t belong in a Git commit — ignored files and secret managers exist so this class of data never touches the object database.

Syntax

There’s no single “sensitive data” command — you combine a few tools. The core one is .gitignore, a plain-text file of patterns Git checks before adding files to the index:

pattern-syntax
  name          matches a file or directory named "name" anywhere in the repo
  /name         matches "name" only at the repository root
  name/         matches only if "name" is a directory
  *.ext         wildcard matches any file ending in .ext
  !pattern      re-includes a file that a broader pattern excluded
  # comment     a comment line, ignored by Git

Other tools referenced in this lesson:

  • git rm --cached <file> — untracks a file (removes it from the index) without deleting it from your working directory.
  • git log -p -- <file> — shows every historical change to a file, useful for confirming whether a secret is present in history.
  • git filter-repo — a third-party, Git-project-recommended tool that rewrites history to remove a file or string from every commit. Install with pip install git-filter-repo.
  • git push --force-with-lease — pushes rewritten history safely, aborting if the remote has commits you haven’t seen.
  • gitleaks / git-secrets — third-party scanners that detect secret-shaped strings before they’re committed.

Examples

Example 1: Ignoring secrets before they’re ever tracked

The cheapest fix is making sure a secret file is never staged in the first place. Suppose you’re starting a new project with a .env file for local configuration:

cat .env

Output:

DATABASE_URL=postgres://user:hunter2@localhost:5432/app
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Before you ever run git add, create a .gitignore that excludes it:

node_modules/
.env
.env.*
!.env.example
*.pem
*.key
git add .gitignore
git commit -m "chore: add .gitignore for secrets and dependencies"
git status

Output:

On branch main
nothing to commit, working tree clean

.env doesn’t show up as untracked at all — Git deliberately hides it from git status and from git add . because it matches a .gitignore pattern. This is the cheapest possible fix: the secret never enters the object database, so there’s no history to clean up later.

Example 2: You already committed a secret, but haven’t pushed yet

Suppose you ran git add . before writing a .gitignore, and .env got committed. Since it hasn’t been pushed, you can fix this without rewriting shared history:

git rm --cached .env
echo ".env" >> .gitignore
git add .gitignore
git commit -m "fix: stop tracking .env and add it to .gitignore"

Output:

rm '.env'
[main 8a2f1c3] fix: stop tracking .env and add it to .gitignore
 2 files changed, 1 insertion(+), 1 deletion(-)
 delete mode 100644 .env

git rm --cached removes the file from the index but leaves it untouched on disk, so your local app keeps working. The new commit’s tree no longer includes the .env blob — but the previous commit that first added it still exists and still contains the secret. If this repo has never been pushed, you can still rewrite that history cheaply before anyone else has a copy. If it’s already public, treat it as leaked: rotate the credential immediately regardless of whether you clean up history afterward.

Example 3: A secret was already pushed to GitHub

This is the expensive case, and order matters. First, rotate the secret — generate a new API key or password and revoke the old one — before doing anything else, because a leaked value must be assumed compromised the moment it left your machine. Only after rotation is it safe to clean up history:

git filter-repo --path .env --invert-paths

Output:

Parsed 42 commits
New history written in 0.31 seconds...
Completely finished after 0.98 seconds.

--invert-paths tells git filter-repo to keep everything except the given path, rewriting every commit that ever touched .env so its blob is gone from all of them. Every commit after the first affected one gets a new SHA, because a commit’s hash depends on its tree and its parent’s hash — change the tree once, and the hash changes for that commit and every descendant. Because this rewrites published history, a force push is required, and every collaborator must re-clone or reset their local branch:

git push origin --force-with-lease --all
git push origin --force-with-lease --tags

--force-with-lease refuses to overwrite the remote branch if someone else pushed commits you haven’t fetched yet, protecting against silently discarding a teammate’s work — always prefer it over a bare --force. Even after this, assume the secret is permanently compromised: GitHub caches, forks, and CI logs may already have captured the value. Rewriting history removes the secret from the canonical repo going forward; it does not undo the exposure.

How It Works Step by Step

When you run git rm --cached .env, Git removes the file’s entry from the index only — the working tree copy is untouched, and no new blob is written since nothing is added, only unstaged. The following git commit writes a new tree object that omits the .env entry, and a new commit object pointing at that tree with the previous commit as its parent. The main branch pointer moves to this new commit. Crucially, the old commit — where .env still appears in the tree — remains reachable by walking parent pointers backward, so git log -p or checking out an older commit still exposes the secret.

git filter-repo works differently: it walks the entire commit graph from the first commit forward, and for every commit whose tree includes the target path, it builds a new tree without it, then a new commit object referencing the new tree and the already-rewritten parent commit. Because a commit’s SHA-1 hashes its parent’s hash too, this cascades — every commit from the first affected one onward gets a new identity. The old branch tip and every old commit behind it become unreferenced by any branch or tag, remaining in .git/objects as loose, unreachable objects until git gc (or GitHub’s background maintenance, for the remote copy) prunes them.

Common Mistakes

Mistake: deleting the file and committing, assuming that’s enough.

rm .env
git add .env
git commit -m "remove secret"

Why it’s wrong: this only removes .env from the newest snapshot. Every prior commit that included it is still in history and still fetchable by anyone who clones the repo. The fix is rewriting history with git filter-repo (or the BFG Repo-Cleaner) and rotating the credential — deleting the file in a new commit accomplishes neither.

Mistake: adding a file to .gitignore after it’s already tracked, assuming that untracks it.

echo ".env" >> .gitignore
git add .gitignore
git commit -m "add gitignore"

Why it’s wrong: .gitignore only affects untracked files. If Git already tracks .env, it stays tracked and keeps showing up in git status and future commits — you must run git rm --cached .env first.

Mistake: force-pushing rewritten history without telling collaborators. Everyone else’s local main still points at the old commits. Their next git pull creates a confusing merge of two unrelated histories, or fails outright. After any history rewrite, every collaborator needs to fetch fresh and reset their local branch to the new history, or re-clone.

Mistake: treating “removed from Git” as “safe again,” and skipping rotation. If a secret was ever pushed, assume it was seen — GitHub’s secret-scanning bots, forks, cached pages, and CI logs can all retain a copy independent of your repository’s current state. Rewriting history is cleanup, not remediation; rotating the credential is the remediation.

Best Practices

  • Create a .gitignore before your first commit, using a language or framework template, so secrets and build artifacts are never staged in the first place.
  • Keep real secrets in environment variables or a secrets manager (GitHub Actions secrets, AWS Secrets Manager, HashiCorp Vault) rather than in files inside the repo at all.
  • Commit a .env.example with placeholder values so teammates know what variables are needed, and explicitly un-ignore it with !.env.example if .env* is broadly ignored.
  • Enable GitHub’s secret scanning and push protection under repository Settings → Code security — push protection rejects a push outright if it detects a recognizable secret pattern.
  • Run a pre-commit secret scanner locally, such as gitleaks protect or git-secrets, so leaks are caught before they ever leave your machine.
  • If a secret leaks, rotate it immediately — treat history cleanup as a secondary step, not the fix.
  • Never put credentials directly in a commit message, a GitHub Actions workflow file, or an issue or PR comment — all are just as permanent and just as public as a tracked file.

Practice Exercises

  1. Create a new repository, add a config.json containing a fake API key, and commit it. Then write a .gitignore entry for config.json and confirm with git status that it’s still tracked. What single command do you need to run to actually stop tracking it while keeping the file on disk?
  2. You discover that three commits ago, someone committed secrets/aws-credentials.json, and it has since been overwritten twice by unrelated fixes. Use git log -p -- secrets/aws-credentials.json to confirm the secret is still visible in history even though the file was later deleted. Describe (without running it) the sequence of steps you’d take before and after running git filter-repo to fully remediate this.
  3. Set up a .gitignore for a Python project that ignores .env, __pycache__/, and *.pyc, but makes an exception so .env.example stays tracked. Verify your pattern works by creating all four files and checking which ones git add -A actually stages.

Summary

  • Git commits are immutable snapshots built from blobs and trees; deleting a file in a new commit does not remove it from earlier commits still reachable in history.
  • .gitignore only prevents untracked files from being staged — it has no effect on files Git already tracks.
  • Use git rm --cached <file> to untrack a file that was committed but not yet pushed, then commit the removal alongside an updated .gitignore.
  • Once a secret is pushed to a remote, rotate the credential immediately — assume it has been seen, regardless of any later cleanup.
  • git filter-repo rewrites every commit that touched a given path, giving each a new SHA-1; this requires a force push and coordination with every collaborator.
  • Prefer environment variables, secret managers, and automated scanners so secrets never reach the object database in the first place — prevention is far cheaper than remediation.