The previous lesson left task-manager with the right files inside and properly treated. What remains is the matter we announced at the close of module 7 and have been deliberately postponing: there is a config.js with the database password in the history, and it has been there for eight months, and the repository has been public for four months so that Diego can contribute from his fork.

This lesson closes three promises made by the course: the one from lesson 04-03, where we saw credential.helper store and warned that it stores credentials in plain text, deferring the details to here; the one from lesson 08-03, where we said that git rm --cached does not delete a secret from the history; and the one from lesson 06-01, where hooks were left as a convenience tool, not a security one.

The approach is preventive and remedial. It is organised into four blocks: why a repository is the worst possible place for a secret, how to detect a leak before and after it happens, exactly what to do if it has already happened (and the order of the steps matters enormously), and how to maintain trust in authentication and in authorship.

A warning up front. This material is educational and general. In regulated environments — personal, health, financial or public-sector data — a credential leak can carry legal notification obligations with strict deadlines. Before you act, tell your security or compliance officer. Rewriting a history can also have audit implications. What follows is good technical practice, not legal advice.

Contents

  1. Why a repository is a terrible place for a secret
  2. What counts as a secret
  3. Correct management: environment variables and secret managers
  4. Detection: client hooks and CI scanning
  5. If it has already leaked: the procedure, in order
  6. Step 1 — Rotate the credential
  7. Step 2 — Rewrite the history with git filter-repo
  8. Step 3 — Coordinate with the team
  9. Step 4 — The copies left on the platforms
  10. Why git rm is no use
  11. Authentication: SSH keys, ssh-agent and credential managers
  12. Signing commits and tags
  13. Hygiene: permissions, protected branches and --force
  14. Before making a repository public

  1. Why a repository is a terrible place for a secret

A Git repository has four properties which, taken together, make it the worst imaginable container for a credential:

1. It is cloned in full. Remember from lesson 01-01 that Git is distributed: git clone does not download the tip, it downloads the whole history. Every person who has ever cloned it has a complete copy of your secret on their disk.

2. It is immutable by design. In lesson 01-04 we saw that a commit is immutable: its SHA is the hash of its content. A blob with the password carries on existing as long as something references it, and deleting it requires rewriting every subsequent commit, that is, breaking the golden rule of lesson 05-01.

3. It is replicated with no control. Forks (Diego has one), mirrors, backups, CI caches, container images that ran git clone, development environments belonging to people who have left the company.

4. It is very easy to search. You do not have to read commit by commit. A single command finds everything:

git log --all -p -S 'password' --oneline
git rev-list --all | xargs git grep -n 'secret' 2>/dev/null

In a public repository, there are tools and services that continuously scan newly published repositories looking for credential patterns. The average time between publishing a valid key and the first attempt to use it is measured in minutes, not days.

flowchart TD
    A["Commit with the secret"] --> B["push to git.example.com"]
    B --> C["Ana's clone"]
    B --> D["Bruno's clone"]
    B --> E["Carla's clone"]
    B --> F["Diego's fork"]
    B --> G["CI cache"]
    B --> H["Backup"]
    F --> I["Clones of the fork"]
    B --> J["Automatic scanners<br/>if it is public"]

Every node in that graph is a copy over which you have no control whatsoever. From that comes the conclusion that governs the whole lesson: the only action that genuinely neutralises a leaked secret is invalidating it. Everything else — rewriting, deleting, forcing — is subsequent clean-up, important but secondary.

  1. What counts as a secret

The list is longer than people assume. All of the following must never be in a repository:

Category Examples
Passwords For databases, internal services, service accounts, email
API keys and tokens From any provider, personal access tokens (PATs), session tokens, webhook keys
Cryptographic keys Private SSH keys (id_rsa, id_ed25519), certificates with a private key (.pem, .p12, .pfx), signing keys
Connection strings postgres://user:password@host/db, mongodb+srv://..., any URL with embedded credentials
Application secrets Session and JWT signing keys, encryption-at-rest keys, generation seeds
Infrastructure credentials Cloud provider access keys, deployment credential files, container registry tokens
Personal data Database dumps with real user data, export files, screenshots with identifiable data
Sensitive configuration URLs of unpublished internal services, network paths, private infrastructure addresses, internal host names

Two categories that people systematically forget:

  • Personal data in test dumps. A "test" dump.sql with real user emails and phone numbers is a personal data breach, with all the legal consequences that entails.
  • Infrastructure information. It is not a credential, but an attacker who knows what your internal servers are called and which ports they use is halfway there.

The practical criterion:

If a value expires, is rotated or can be revoked, it is a secret. If a stranger seeing it could do something you would not want, it is a secret. When in doubt, it is a secret.

  1. Correct management: environment variables and secret managers

The structural rule is simple:

Configuration that changes between environments goes outside the code. Secrets go outside the repository, always.

Level 1: environment variables and an ignored .env

It is what we saw at the end of lesson 08-03, and for a small project it is enough.

// app.js — the code reads from the environment, it never contains the value
const config = {
  databaseUrl: process.env.DATABASE_URL,
  mailKey:     process.env.MAIL_API_KEY,
  sessionSecret: process.env.SESSION_SECRET,
  port: process.env.PORT || 3000,
};

// Fail early and clearly if something is missing
for (const [key, value] of Object.entries(config)) {
  if (value === undefined) {
    throw new Error(`The environment variable for "${key}" is missing. Copy .env.example to .env.`);
  }
}

With the real .env ignored and the .env.example versioned with obviously fake values:

.env
.env.*
!.env.example
*.pem
*.key
*.p12
id_rsa
id_ed25519
credentials*.json

That validation loop at start-up is more important than it looks: it turns a silent failure in production ("why aren't the emails arriving?") into an immediate, explicit error at start-up.

Level 2: a secret manager

For anything that reaches production, a .env on a machine's disk falls short. A secret manager is a service that stores secrets encrypted and hands them out on demand to whoever has permission.

Advantage Compared with the .env
Encryption at rest The .env is plain text on disk
Access control by identity The .env is readable by anybody who gets onto the machine
Auditing It records who read what and when
Rotation It can change the value without deploying anything
Expiry Short-lived credentials, generated on the fly
Centralised revocation A single place to cut off access

There are several families of them: the ones built into each cloud provider, the self-hosted general-purpose ones, and those of the CI platforms themselves (the "repository secrets", which we saw in use in the workflow of lesson 07-06). Which one to choose depends on the infrastructure; what matters is the principle.

A widely used intermediate pattern, when you want to version the configuration encrypted: file-encryption tools that keep an .env.encrypted in the repository which can only be decrypted with a key that lives somewhere else. It is acceptable on two conditions: that the algorithm is sound and that the decryption key is never in the repository. And bear in mind that if that key ever leaks, the whole encrypted history is exposed retroactively.

The rule of least privilege

Wherever they live, credentials should have the minimum permission and the shortest life possible:

  • A CI token that only needs to read packages must not be able to write to the repository.
  • A development credential must never work in production.
  • Tokens with an expiry are preferable to permanent ones, even though they mean more work.

That way, when a leak happens — and it will — the damage is bounded in advance.

  1. Detection: client hooks and CI scanning

The best leak is the one that never gets committed. There are three barriers, in order of proximity to the developer.

Barrier 1: a pre-commit hook

Picking up on lesson 06-01, a hook that rejects the commit if it detects suspicious patterns in what is about to go in:

#!/usr/bin/env bash
# .githooks/pre-commit — basic secret detection
# Scans ONLY what is staged, which is what will go into the commit.

failures=0

# 1. Files that must never be committed, by name
forbidden_patterns='(^|/)\.env$|(^|/)\.env\.[^e]|\.pem$|\.p12$|\.pfx$|(^|/)id_rsa$|(^|/)id_ed25519$|credentials.*\.json$'

while IFS= read -r file; do
  if echo "$file" | grep -qE "$forbidden_patterns"; then
    echo "BLOCKED: '$file' looks like it contains credentials." >&2
    failures=1
  fi
done < <(git diff --cached --name-only --diff-filter=ACM)

# 2. Suspicious content in what is staged
#    Generic patterns: long values assigned to revealing names
if git diff --cached -U0 | grep -nE \
   '^\+.*(passphrase|password|passwd|secret|api[_-]?key|token|private[_-]?key)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{12,}' ; then
  echo "" >&2
  echo "BLOCKED: possible credential in the staged content." >&2
  failures=1
fi

# 3. Private key blocks
if git diff --cached | grep -q 'BEGIN [A-Z ]*PRIVATE KEY'; then
  echo "BLOCKED: private key block detected." >&2
  failures=1
fi

# 4. Connection strings with embedded credentials
if git diff --cached -U0 | grep -nE '^\+.*[a-z][a-z0-9+.-]*://[^/[:space:]]+:[^@[:space:]]+@'; then
  echo "BLOCKED: URL with embedded credentials." >&2
  failures=1
fi

if [ "$failures" -ne 0 ]; then
  echo "" >&2
  echo "If it is a false positive, check it carefully before using --no-verify." >&2
  echo "If it is real: do NOT use --no-verify. Move the value out to an environment variable." >&2
  exit 1
fi

exit 0
chmod +x .githooks/pre-commit
git config --local core.hooksPath .githooks

There are specialised tools far better than this script — with hundreds of provider-specific patterns, entropy calculation and exclusion lists — and they plug in as a hook with managers such as Husky, exactly as we saw in lesson 06-01. The script above is there to help you understand the mechanism and as a minimal safety net.

And the usual warning, which lesson 07-06 turned into a principle: --no-verify dodges it. A client-side hook is not a security control. It is a help for the honest person in a hurry, which is precisely the profile that leaks secrets.

Barrier 2: CI scanning

Here there really is a control, because it runs where the developer is not in charge (lesson 07-06):

# .github/workflows/ci.yml (fragment)
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # the scan needs the history

      - name: Look for secrets in the PR's commits
        run: |
          BASE="${{ github.event.pull_request.base.sha }}"
          # The specialised detection tool would go here.
          # As a minimal safety net, a check for forbidden files:
          if git diff --name-only "$BASE..HEAD" \
             | grep -E '\.env$|\.pem$|id_rsa$|\.p12$'; then
            echo "::error::A file that looks like it contains credentials has been added"
            exit 1
          fi

      - name: Check that .env.example is up to date
        run: |
          # The keys the code reads from the environment must be documented
          grep -ohE 'process\.env\.[A-Z_]+' -r . --include='*.js' \
            | sed 's/process\.env\.//' | sort -u > /tmp/used.txt
          grep -oE '^[A-Z_]+' .env.example | sort -u > /tmp/documented.txt
          if ! comm -23 /tmp/used.txt /tmp/documented.txt | grep -q .; then
            echo "Every variable is documented."
          else
            echo "::error::Variables missing from .env.example:"
            comm -23 /tmp/used.txt /tmp/documented.txt
            exit 1
          fi

Declared as a required check on the protected branch, this barrier really does stop the commit reaching main. But note the nuance: the commit already exists on the published branch. Detection in CI stops it entering the mainline; it does not stop the secret already being on the server. That is why the three barriers complement one another and none of them is superfluous.

Barrier 3: periodic scanning of the whole history

The previous two look at what is new. Every now and then it is worth looking at the old, because detection rules improve and because the old history was written before there were any barriers at all:

# Look for a pattern across the whole history, on every branch
git log --all -p -S 'MAIL_API_KEY' --oneline

# Search the content of every reachable commit
git rev-list --all | xargs git grep -n 'BEGIN RSA PRIVATE KEY' 2>/dev/null

# See the content of a file in a particular commit
git show 4c8a9f0:config.js

Schedule this scan in CI to run weekly. It is cheap and it finds what slipped in before the rules existed.

  1. If it has already leaked: the procedure, in order

It has happened. In task-manager, Ana ran the history scan and found this:

git log --all --oneline -- config.js
7c2d9e1 chore: remove config.js from the repository
4c8a9f0 chore: initial database configuration
git show 4c8a9f0:config.js
module.exports = {
  database: {
    host: "internal-db.example.com",
    user: "taskmanager_app",
    password: "a-fictitious-example-password",
  },
  mailApiKey: "an-example-key-that-is-not-real",
};

Commit 7c2d9e1 "removed" the file six months ago. The secret is still perfectly accessible. And the repository has been public for four months.

The procedure has four steps and the order is not negotiable:

flowchart TD
    A["1. ROTATE the credential<br/>IMMEDIATELY"] --> B["2. Rewrite the history<br/>git filter-repo"]
    B --> C["3. Coordinate with the team<br/>every clone becomes obsolete"]
    C --> D["4. Request a cache purge<br/>and assume copies remain"]
    A -.->|"the only thing that<br/>really neutralises it"| A

The reason for the order is a single sentence, and it is worth committing to memory:

Rewriting the history first is the classic mistake. It takes hours, it requires coordinating the whole team, and for all that time the credential is still valid. What is more, the rewriting process and the announcement to the team make a lot of noise: if somebody is watching, you have just pointed out exactly where to look. First you invalidate the secret; then, calmly, you clean up.

  1. Step 1 — Rotate the credential

Rotating means invalidating the leaked value and generating a new one. It is the only thing that genuinely neutralises the leak, because it is the only thing that does not depend on controlling copies you do not control.

First things first, in the first few minutes:

  1. Revoke or change the credential at the source service. Not "I'll change it when I deploy": now. If it is an API key, revoke it in the provider's dashboard. If it is a database password, change it. If it is an SSH key, remove the authorised public key.
  2. Generate the new one and store it where it belongs (section 3), never in the repository.
  3. Deploy the new one to the environments that need it.
  4. Review the service's access logs from the date of the leaked commit. It is the only way to know whether it was used. In our example, that means the last eight months.
  5. Document the incident: what leaked, since when, what was done and when. It is essential in a regulated environment and very useful in any other.

The order within step 1

If the service allows it, create the new credential first, deploy it and revoke the old one afterwards. That way there is no service interruption. If it does not allow that, or if there are signs of misuse, revoke first and accept the outage: a ten-minute interruption is infinitely preferable to a live credential in a public repository.

The uncomfortable conversation

If the repository has been public, you have to assume the worst: the credential is compromised, regardless of whether the logs show anything odd. Automatic scanners operate in minutes. Act as if it had been used and review what could have been done with it.

And this is where the warning at the start becomes concrete: if that credential gave access to personal data or to systems with regulatory requirements, the breach notification may have a legal deadline. Inform your security or compliance officer before you move on to step 2. It is not bureaucratic box-ticking: the clock starts from the moment of detection.

  1. Step 2 — Rewrite the history with git filter-repo

With the credential already dead, you can clean up the history calmly. The goal is for the leaked value to stop existing in the repository's objects.

The tool

git filter-repo is the currently recommended tool. It supersedes the old git filter-branch, which still exists but is very slow and has so many pitfalls that Git itself advises against its use in its own documentation. The alternative is BFG Repo-Cleaner, simpler and faster for the two usual cases (deleting files and replacing strings), though less flexible.

# Installation (it varies by system)
pip install git-filter-repo
# or your distribution's package

Before you start: two precautions

# 1. A full backup. It is not optional.
cd ~/projects
cp -r task-manager task-manager-backup-$(date +%Y%m%d)

# 2. Work on a FRESH clone with no filters
#    (filter-repo refuses to operate on a "dirty" repository)
git clone --mirror ssh://[email protected]/team/task-manager.git cleanup.git
cd cleanup.git

--mirror clones every reference: branches, tags, notes. It is the right thing here, because the secret may be on an old branch or on a tag.

Case A: removing a file from the whole history

# Removes config.js from EVERY commit, on every branch
git filter-repo --path config.js --invert-paths
Parsed 847 commits
New history written in 3.21 seconds...
Completely finished after 4.87 seconds.

It can be applied to several files or to whole directories:

git filter-repo \
  --path config.js \
  --path .env \
  --path credentials/ \
  --invert-paths

Case B: replacing the value, keeping the file

Often the file has to carry on existing; what has to go is the value. --replace-text takes a rules file:

cat > /tmp/replacements.txt <<'EOF'
a-fictitious-example-password==>***REMOVED***
an-example-key-that-is-not-real==>***REMOVED***
regex:password:\s*"[^"]*"==>password: "***REMOVED***"
EOF

git filter-repo --replace-text /tmp/replacements.txt

The format of each line: literal==>replacement, or regex:expression==>replacement. If ==>replacement is omitted, ***REMOVED*** is used.

That rules file contains the secrets in plain text. Delete it as soon as you have finished:

shred -u /tmp/replacements.txt 2>/dev/null || rm -f /tmp/replacements.txt

Checking the result

# The file must no longer appear in any commit
git log --all --oneline -- config.js       # no output

# Nor the value in any content
git rev-list --all | xargs git grep -n 'a-fictitious-example' 2>/dev/null   # no output

# Orphan objects, out (see lesson 08-06)
git reflog expire --expire=now --all
git gc --prune=now --aggressive

Publishing the rewrite

filter-repo deliberately removes the origin remote as a safety measure, so that you cannot publish without thinking about it. You have to add it back:

git remote add origin ssh://[email protected]/team/task-manager.git

# This rewrites ALL the branches and tags on the server.
# It requires temporarily removing the protection on main.
git push --force --all
git push --force --tags

This is the most delicate moment of the whole procedure. You are rewriting the published history of the entire project, exactly what the golden rule of lesson 05-01 forbids. It is justified — it is one of the very few legitimate exceptions — but it demands the coordination of step 3. With --mirror and --force --all, --force-with-lease does not apply in the same way; the protection here is organisational: nobody pushes anything during the agreed window.

The effects you have to accept

Effect Detail
Every SHA changes From the first modified commit onwards. Links to commits in tickets, documentation and PRs stop working
Every clone becomes obsolete Ana, Bruno, Carla and Diego have divergent histories
Open PRs break They point at commits that no longer exist
Tags are rewritten Annotated and signed ones lose their signature (section 12)
.git-blame-ignore-revs becomes obsolete The SHAs it contains no longer exist; it has to be regenerated
CI can fail Caches with keys based on SHAs that no longer exist

  1. Step 3 — Coordinate with the team

Rewriting without warning turns a controlled incident into chaos. The sequence:

Beforehand

  1. Announce the window in advance, on the channel everybody reads.
  2. Have everybody merge or close their branches. Whatever is left open will have to be redone.
  3. Have everybody push what they have and then stop: nobody pushes anything during the window.
  4. Temporarily remove the protection on main (needed for the --force), and make a note to restore it.

Afterwards: instructions for the team

The recommended and safest option is to clone again:

# 1. Save any pending work outside the repository
cd ~/projects/task-manager
git diff > ~/my-pending-changes.patch     # just in case

# 2. Rename the old clone (do not delete it yet)
cd ~/projects
mv task-manager task-manager-OLD

# 3. Clone again
git clone ssh://[email protected]/team/task-manager.git
cd task-manager

# 4. Reconfigure the local settings that do not travel (lessons 08-01 to 08-04)
git config --local core.hooksPath .githooks
git config --local commit.template .gitmessage
git config --local blame.ignoreRevsFile .git-blame-ignore-revs

# 5. Once everything works, delete the old one
rm -rf ~/projects/task-manager-OLD

If somebody has unpublished work they want to rescue, it gets rebased onto the new history:

# In the new clone, bring over the commits from the old one
git remote add old ~/projects/task-manager-OLD
git fetch old
git switch -c GT-155-recovered old/GT-155
git rebase --onto main $(git merge-base main old/GT-155) GT-155-recovered

There may be conflicts: the commits being rebased were written on top of a base that no longer exists. With short branches — the module 7 argument in favour of integrating often — the cost is small; with a three-week-old branch, it is painful. It is one more argument in favour of short branches.

Diego's case

Diego has a fork, that is, an independent repository on the server. Rewriting origin does not touch his fork: the secret is still intact there, and with it in every clone of his fork.

# Diego, in his fork (picking up the triangle from lesson 07-01)
git remote -v
origin    ssh://[email protected]/diego/task-manager.git (fetch)
upstream  ssh://[email protected]/team/task-manager.git (fetch)

You have to explicitly ask him to delete his fork and create it again from the now-clean repository, or to apply the same rewrite to it. And the same goes for any other existing fork: in a public repository there may be dozens you do not even know about. It is one of the reasons why step 1 — rotating — is the only thing that really closes the matter.

After everything

  1. Restore the protection on main and check that it is as it was.
  2. Regenerate .git-blame-ignore-revs with the new SHAs.
  3. Verify the CI and clear the caches that depend on old SHAs.
  4. Check again that the secret is gone: git rev-list --all | xargs git grep ....

  1. Step 4 — The copies left on the platforms

This is the step most people skip, and it is the one that explains why step 1 is non-negotiable.

Even if you have rewritten the history and force-pushed, accessible copies may remain on the hosting platforms:

Where it remains Why
Commit view by SHA Many platforms keep "orphan" objects and serve them if you know the SHA, sometimes for a very long time
Closed pull requests They keep a copy of the commits that were proposed, even if the branch was deleted
Review comments They quote code fragments verbatim; if the secret was on a commented line, it is still there
Forks Independent repositories, with their own copy
Platform caches and CDN Generated pages and packages, served from cache
CI caches Clones and artefacts from previous runs
Search engine indexes If it was public, the content may be indexed
Third-party archiving services Automatic mirrors and replicas of public repositories

What to do:

  1. Contact the platform's support and request the purge of cached references and orphan objects. Almost all of them have a procedure for this; it usually requires an explicit request.
  2. Review and delete the pull requests that contain the secret in their diff or in their comments, if the platform allows it.
  3. List the known forks and request their deletion or clean-up.
  4. Clear the CI caches.
  5. And above all: assume that some copy will survive. Always.

This is the underlying reason for the order of the procedure. You cannot guarantee that the secret will disappear from everywhere. The only thing you control 100 % is making the value useless. That is why rotating is step 1, and that is why the other three steps are important but secondary hygiene.

  1. Why git rm is no use

It deserves a section of its own because it is the most widespread and most dangerous misunderstanding, and because we announced it back in lesson 08-03.

git rm config.js
git commit -m "chore: remove the configuration file with credentials"
git push

What has happened:

  • The file is no longer on the tip of the branch.
  • The file is still in commit 4c8a9f0 and in all the intermediate ones.
  • The blob with the content is still in the object database.
  • Every clone still has it.
# The demonstration
git log --all --oneline -- config.js
git show 4c8a9f0:config.js          # there it is, complete

Remember the data model of lesson 01-04: every commit points to a tree, and that tree points to the blobs of that version. Deleting the file creates a new commit whose tree no longer includes it. The earlier trees are not modified: they are immutable. The blob carries on existing, referenced by them.

It is worse than doing nothing, for two reasons:

  1. It gives a false sense of security. "I've already removed it" is the sentence that stops anybody rotating the credential.
  2. It points at where to look. A commit called "remove the file with credentials" is an invitation to search the previous commit.
Command Removes from the tip Removes from the history Neutralises the secret
git rm Yes No No
git rm --cached Yes (from the index) No No
git filter-repo Yes Yes (in your repository) No (copies remain)
Rotating the credential Yes

The last row is the whole lesson summarised in a table.

  1. Authentication: SSH keys, ssh-agent and credential managers

In lesson 04-03 we saw the methods for authenticating with the remote and left the security part pending. Here it is.

SSH keys with a passphrase

An SSH key with no passphrase is a file that gives full access to the repository to anybody who copies it. With a passphrase, the file on its own is useless.

# Generate a modern key, with a passphrase
ssh-keygen -t ed25519 -C "[email protected]"
# Enter passphrase: (type a long phrase, do not leave it empty)

ed25519 is preferable to RSA: shorter, faster and with equivalent or better security. If you need compatibility with old systems, rsa with -b 4096.

ssh-agent: the passphrase once per session

The objection to the passphrase is that you have to type it every time. ssh-agent keeps it in memory, decrypted, for the session:

# Start the agent (it is usually already running)
eval "$(ssh-agent -s)"

# Add the key: it asks for the passphrase ONCE
ssh-add ~/.ssh/id_ed25519

# With an 8-hour expiry, which is better practice
ssh-add -t 8h ~/.ssh/id_ed25519

# See which keys it has loaded
ssh-add -l

# Unload them all (at the end of the day, or when leaving the laptop)
ssh-add -D

On macOS, the system keychain can keep it across restarts:

ssh-add --apple-use-keychain ~/.ssh/id_ed25519

The recommended configuration in ~/.ssh/config:

Host git.example.com
    User git
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
    AddKeysToAgent yes

IdentitiesOnly yes stops SSH offering all your keys to the server, which also prevents lockouts from too many attempts.

File permissions

SSH refuses to use a key with lax permissions, and quite right too:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Credential managers versus store

For HTTPS with a token, lesson 04-03 introduced credential.helper and warned about store. Now the full comparison:

Helper Where it stores Encrypted Recommendation
store ~/.git-credentials, plain text No Avoid it. Any process that can read your $HOME gets the token
cache Memory, with an expiry Not needed (it never touches the disk) Acceptable for occasional use
manager (Git Credential Manager) System keychain Yes Recommended, cross-platform
osxkeychain macOS keychain Yes Recommended on macOS
libsecret GNOME/KDE keyring Yes Recommended on desktop Linux
wincred Windows Credential Manager Yes Recommended on Windows
# Check what you have configured
git config --get credential.helper

# If it is "store", change it NOW
git config --global credential.helper manager       # cross-platform
git config --global credential.helper osxkeychain   # macOS (Bruno)
git config --global credential.helper libsecret     # Linux (Ana)
git config --global credential.helper manager       # Windows (Carla)

# And check whether anything is left in the plain text file
cat ~/.git-credentials 2>/dev/null   # if it has content, that token is exposed

If you find tokens in ~/.git-credentials, rotate them (step 1 of section 6) and delete the file. They have been sitting in plain text on your disk all this time.

About personal access tokens

  • Least privilege: only the permissions you need.
  • Short expiry: renewing them is a minor nuisance compared with a leaked permanent token.
  • One per tool: if one is compromised, you revoke that one and not all of them.
  • Never in the remote's URL: https://user:[email protected]/... leaves the token in .git/config, in plain text and visible in any git remote -v you paste into a chat.
# Check that you have no URL with embedded credentials
git config --get-regexp '^remote\..*\.url' | grep '@' | grep -v '^remote.[a-z]*.url ssh://git@'

  1. Signing commits and tags

There is an uncomfortable fact about Git that is worth stating plainly:

The author of a commit is a text field that you write yourself. There is no verification whatsoever.

git -c user.name="Ana Ferrer" -c user.email="[email protected]" \
    commit -m "feat: add a back door"

That commit appears under Ana's name in git log, in git blame and on the platform. Anybody with write access — or anybody who sends a PR — can do it. The cryptographic signature is the answer.

With GPG

# 1. Generate a key
gpg --full-generate-key
# Type: RSA and RSA (or ECC) · Size: 4096 · Expiry: 2y
# Name and email: the SAME as in user.name and user.email

# 2. Find its identifier
gpg --list-secret-keys --keyid-format=long
sec   ed25519/A1B2C3D4E5F6A7B8 2026-08-01 [SC] [expires: 2028-08-01]
      Fingerprint = ....
uid   Ana Ferrer <[email protected]>
# 3. Configure Git
git config --global user.signingkey A1B2C3D4E5F6A7B8
git config --global commit.gpgsign true      # sign ALWAYS
git config --global tag.gpgSign true         # and the tags too

# 4. Export the public key to upload it to the platform
gpg --armor --export A1B2C3D4E5F6A7B8

If the GPG agent cannot find the terminal to ask for the passphrase:

export GPG_TTY=$(tty)     # add it to your ~/.bashrc or ~/.zshrc

With SSH (simpler, Git 2.34+)

Since Git 2.34 you can sign with the same SSH key you already use to authenticate. It is noticeably simpler to set up:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgSign true

For local verification to work, you need an allowed signers file:

cat > ~/.ssh/allowed_signers <<'EOF'
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...anas-public-key
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...brunos-public-key
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...carlas-public-key
EOF

git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers

Verifying

# One particular commit
git show --show-signature HEAD

# The history with the signature status
git log --show-signature -3

# In compact form: %G? gives G (good), B (bad), U (unknown), N (unsigned)
git log --pretty="%h %G? %an %s" -10
9f8e7d6 G Ana Ferrer   feat(filters): add the filter by label
7c6b5a4 G Bruno Salas  fix(sync): avoid duplicating tasks
4d3c2b1 N Carla Vidal  docs: update the README

Carla's N indicates that that commit is not signed.

And for tags, closing off what we saw in lesson 05-05:

git tag -s v1.5.0 -m "Version 1.5.0"
git tag -v v1.5.0

What a signature guarantees and what it does not

It guarantees It does not guarantee
That whoever controls that key created the commit That the code is correct or secure
That the content has not been altered since the signature That the key is not compromised
Traceability for an audit That the person is who they say they are (that depends on how the key was verified)
That a third party cannot impersonate a signer That the commit has been reviewed

The practical limitations you have to know about:

  • Signing is not a substitute for review. A signed malicious commit is still malicious.
  • Keys get compromised. That is why they expire and why revocation certificates exist (generate yours when you create the key and keep it somewhere safe).
  • Rewriting the history destroys the signatures. A rebase or a filter-repo generates new commits, unsigned. It is a side effect of step 2 of section 7 that has to be anticipated.
  • Squashing loses the signatures of the original commits. It links to the integration policy of lesson 08-02: if the signature is an audit requirement, a server-side squash signed by the platform is not the same as commits signed by their authors.
  • Requiring signatures from outside contributors has a cost. You would have to ask Diego to set up GPG or SSH signing before his first PR. It is reasonable in a project with audit requirements and an unnecessary barrier in a small project.

You can require signatures on the platform's protected branches, alongside the checks from lesson 07-06.

  1. Hygiene: permissions, protected branches and --force

Permissions

  • Least privilege for people too. Diego does not need write access: he works from his fork and that is exactly right (lesson 07-01).
  • Review access periodically. People change teams and companies; permissions are rarely withdrawn on their own. A quarterly review is enough.
  • Beware of service accounts and deployment tokens. They usually have broad permissions and never expire, and nobody reviews them because "they work".
  • Third-party applications and integrations connected to the repository also have access. Audit what is connected and remove what is not used.

Protected branches

Picking up lesson 07-06, a well configured protected branch prevents accidents and attacks:

Rule What it prevents
Forbid direct push Somebody skipping the review
Require a PR with approvals A change getting in without anybody looking at it
Require green checks Code that does not build, or with detected secrets, getting in
Forbid --force The published history being rewritten
Forbid deleting the branch An irreversible accident
Require signed commits Impersonation of authorship
Require the branch to be up to date Semantic conflicts

--force

Remember from lesson 04-05: --force overwrites whatever is on the server without looking. If Bruno pushed something while you were working, it disappears.

# NEVER out of habit
git push --force

# ALWAYS this instead
git push --force-with-lease

--force-with-lease checks that the remote state is what you thought it was; if somebody has pushed something, the operation is rejected. You can make it permanent with an alias:

git config --global alias.pushf 'push --force-with-lease'

The only legitimate exception to plain --force is the one in section 7: the coordinated rewrite after a leak, with the agreed window and everybody standing still.

A detail that gets forgotten: the author data

git log -1 --pretty="%an <%ae>"

If you work on a public project with your corporate email, or the other way round, check what you are publishing. You can use per-directory configuration (lesson 01-05):

# ~/.gitconfig
[includeIf "gitdir:~/projects/work/"]
    path = ~/.gitconfig-work
[includeIf "gitdir:~/projects/personal/"]
    path = ~/.gitconfig-personal

  1. Before making a repository public

Making a private repository public is an operation that is irreversible in practice: even if you make it private again five minutes later, you have to assume that somebody cloned it. The checklist:

# 1. Look for secret patterns across the WHOLE history
git rev-list --all | xargs git grep -nE \
  '(passphrase|password|passwd|secret|api[_-]?key|token)[[:space:]]*[=:]' 2>/dev/null | head -50

# 2. Private key blocks
git rev-list --all | xargs git grep -n 'BEGIN [A-Z ]*PRIVATE KEY' 2>/dev/null

# 3. URLs with embedded credentials
git rev-list --all | xargs git grep -nE '[a-z]+://[^/[:space:]]+:[^@[:space:]]+@' 2>/dev/null

# 4. Suspicious files that have ever existed
git log --all --pretty=format: --name-only --diff-filter=A \
  | sort -u | grep -iE '\.env|\.pem$|\.key$|id_rsa|credential|secret'

# 5. The largest files (often they are data dumps)
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1=="blob" {print $3, $4}' | sort -rn | head -20

And the manual review:

  • [ ] Are there personal emails or internal addresses in the commit messages?
  • [ ] Are there host names, internal IPs or network paths in the configuration?
  • [ ] Are there database dumps with real data?
  • [ ] Are there screenshots with identifiable data?
  • [ ] Are there code comments that reveal known, unfixed vulnerabilities?
  • [ ] Does the README.md describe internal infrastructure?
  • [ ] Are there CI configuration files with secret names that give clues?
  • [ ] Is the licence the right one and is the LICENSE file there?

In a regulated environment, this checklist is not signed off by whoever wrote the code. It must be reviewed by the security or compliance officer before publishing. It is not bureaucracy: it is the only way of getting somebody with a different perspective to look at the same thing.

Common Mistakes and Tips

Mistake 1: believing that git rm removes a secret. It is the central mistake of the lesson. It only takes it off the tip; the history and every clone still hold it.

Mistake 2: rewriting the history before rotating. It is the reverse of the correct order. The rewrite takes hours, it makes noise and for all that time the credential is still alive. Rotate first, always.

Mistake 3: not rotating because "the logs show no odd access". Absence of evidence is not evidence of absence. If it was in a public repository, it is considered compromised.

Mistake 4: rewriting without telling the team. Every clone ends up divergent, the open PRs break and somebody will end up reintroducing the old history with a push from their old clone.

Mistake 5: forgetting the forks. Rewriting origin does not touch Diego's fork. If the repository is public, there may be forks you do not even know about.

Mistake 6: carrying on with credential.helper store. It stores tokens in plain text in your $HOME. Change it for your system's manager and rotate whatever was inside.

Mistake 7: SSH keys with no passphrase. The file on its own gives full access. ssh-agent removes the inconvenience of typing it.

Mistake 8: a token in the remote's URL. It ends up in .git/config in plain text and shows up in any git remote -v that somebody pastes into a chat.

Mistake 9: real values in .env.example. It breaks the whole pattern: the versioned file becomes the one with the secret in it.

Mistake 10: trusting the pre-commit hook as a control. --no-verify dodges it. It is a help, not a control; the control is in CI on a protected branch.

Tip 1: run the full history scan today. The commands in section 14 take seconds. You may find something from years ago.

Tip 2: least privilege and short expiry on every credential. When the leak happens — and it will — the damage will be bounded in advance.

Tip 3: set up the detection before you need it. A pre-commit hook plus a CI scan cost an afternoon and save you the four-step procedure.

Tip 4: document the incident procedure in the README.md. On the day it happens, nobody will have a cool enough head to improvise it. Write down the four steps and who to tell.

Tip 5: sign your commits. With SSH it takes three commands and it gives real traceability of authorship.

Tip 6: --force-with-lease always, with an alias. It eliminates a whole category of accidents.

Tip 7: in regulated environments, tell somebody before acting. Notification deadlines start on detection, not when you finish cleaning up.

Exercises

Exercise 1: finding the secret in the history

Set up the task-manager scenario and practise the detection:

  1. Create a repository and commit a config.js with a fictitious password.
  2. Add three more commits of normal work.
  3. "Remove" the file with git rm and commit.
  4. Show, with three different commands, that the password is still accessible.
  5. Explain, drawing on the data model of lesson 01-04, why it is still there.

Exercise 2: the complete procedure

Starting from the repository of exercise 1:

  1. List the four steps of the procedure in order, in writing, and justify why rotating comes first.
  2. Make a backup of the repository.
  3. Remove config.js from the whole history with git filter-repo --path ... --invert-paths.
  4. Verify that it no longer appears, using the three commands from exercise 1.
  5. Repeat the exercise with --replace-text, replacing only the value and keeping the file.
  6. Compare the SHAs before and after. What does that mean for the team? And for .git-blame-ignore-revs?
  7. List which copies of the secret would still exist even if the process went perfectly.

Exercise 3: signing and verification

  1. Configure commit signing with SSH (gpg.format=ssh), using a new key created for the exercise.
  2. Create the allowed signers file and configure gpg.ssh.allowedSignersFile.
  3. Make one signed commit and one unsigned one (with --no-gpg-sign).
  4. Show the history with %G? and tell them apart.
  5. Sign an annotated tag and verify it with git tag -v.
  6. Run git rebase -i HEAD~2 with a reword and check what happens to the signature. Explain the relationship with step 2 of the leak procedure.

Solutions

Solution 1:

mkdir /tmp/practice-sec && cd /tmp/practice-sec && git init -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

cat > config.js <<'EOF'
module.exports = {
  database: {
    host: "internal-db.example.com",
    user: "taskmanager_app",
    password: "a-fictitious-example-password",
  },
};
EOF
echo "console.log('task-manager');" > app.js
git add . && git commit -m "chore: initial database configuration"
# 2. Normal work on top
for i in 1 2 3; do
  echo "// change $i" >> app.js
  git commit -am "feat(app): change $i"
done

# 3. "Remove" the file
git rm config.js && git commit -m "chore: remove config.js from the repository"
ls    # config.js is no longer on disk
# 4a. The file's history
git log --all --oneline -- config.js
7c2d9e1 chore: remove config.js from the repository
4c8a9f0 chore: initial database configuration
# 4b. The content in a particular commit
git show 4c8a9f0:config.js
module.exports = {
  database: {
    host: "internal-db.example.com",
    user: "taskmanager_app",
    password: "a-fictitious-example-password",
  },
};
# 4c. A blind content search across the whole history
git rev-list --all | xargs git grep -n 'a-fictitious-example' 2>/dev/null
4c8a9f0:config.js:5:    password: "a-fictitious-example-password",
# Extra: the blob is still a live object
git rev-parse 4c8a9f0:config.js          # the blob's SHA
git cat-file -p $(git rev-parse 4c8a9f0:config.js)

5. Why it is still there. According to the data model of lesson 01-04, a commit points to a tree, and that tree lists the blobs of that version of the project. Git's objects are immutable: their SHA is the hash of their content, so modifying them is impossible by construction.

git rm modifies nothing that already exists: it creates a new commit whose tree no longer includes config.js. The trees of 4c8a9f0 and of the three following commits stay exactly the same, they carry on listing the blob with the password, and that blob is still reachable from main by walking the history backwards. As long as a reachable commit references it, git gc will never delete it.

The only way for it to disappear is to rewrite every commit from the first one that contained it, generating new trees without it. That changes their SHAs and, in cascade, those of all their descendants. It is exactly what git filter-repo does, and it is the reason it is such a disruptive operation.

Solution 2:

1. The four steps:

# Step Why in this order
1 Rotate/revoke the credential It is the only thing that genuinely neutralises it. It does not depend on controlling copies you do not control. It is fast: minutes
2 Rewrite the history (git filter-repo) It cleans up your repository. It takes hours with the coordination, and during that time the old credential is already useless
3 Coordinate with the team Every clone ends up divergent; without this, somebody reintroduces the old history
4 Request the purge on the platform Copies remain in PRs, caches and forks. It is last because it is what you control least

Rotating comes first for three cumulative reasons: (a) it is the only thing that reliably works, since you cannot guarantee the removal of every copy; (b) it is the fastest, whereas the rewrite requires coordinating the whole team; and (c) the rewrite makes a lot of noise — announcements to the team, commits that change, broken PRs — and if somebody is watching, you point out exactly where to look while the credential is still alive.

# 2. Backup
cd /tmp && cp -r practice-sec practice-sec-backup

# 3. Remove the file from the whole history
cd /tmp/practice-sec
git filter-repo --path config.js --invert-paths --force
Parsed 5 commits
New history written in 0.03 seconds...
Completely finished after 0.11 seconds.
# 4. Verification
git log --all --oneline -- config.js                                # no output
git rev-list --all | xargs git grep -n 'a-fictitious' 2>/dev/null   # no output
git show 4c8a9f0:config.js
fatal: invalid object name '4c8a9f0'

The commit does not even exist any more: it was rewritten with a different SHA.

# 5. The variant that keeps the file
cd /tmp && rm -rf practice-sec2 && cp -r practice-sec-backup practice-sec2 && cd practice-sec2

cat > /tmp/replacements.txt <<'EOF'
a-fictitious-example-password==>***REMOVED***
EOF

git filter-repo --replace-text /tmp/replacements.txt --force
rm -f /tmp/replacements.txt      # the rules file contained the secret

git log --all --oneline -- config.js     # the file IS still in the history
git rev-list --all | xargs git grep -n 'REMOVED' 2>/dev/null
a9f8e7d:config.js:5:    password: "***REMOVED***",

The file is kept with all its structure; only the value has disappeared from every version.

# 6. Compare the SHAs
cd /tmp/practice-sec-backup && git log --oneline
cd /tmp/practice-sec && git log --oneline

Every SHA is different, from the first rewritten commit to the tip. Implications:

  • For the team: every clone has a divergent history. A git pull would produce an enormous tangle. Everybody has to clone again and redo their live branches (step 3).
  • For .git-blame-ignore-revs: it contains SHAs that no longer exist, so it becomes useless and has to be regenerated with the new ones. The same goes for any SHA quoted in tickets, documentation, PR comments or CI caches.
  • For the signatures (section 12): the rewritten commits lose their signature, because they are new objects.

7. Copies that would still exist even if everything went perfectly:

  • The clones belonging to Ana, Bruno and Carla, until they clone again.
  • Diego's fork, which is an independent repository that rewriting origin does not touch.
  • The clones other people have made of Diego's fork.
  • The closed pull requests on the platform, with their diffs.
  • The review comments that quoted those lines.
  • The platform's caches and the orphan objects accessible by SHA.
  • The CI caches and artefacts.
  • The server backups.
  • If it was public: search engine indexes, archiving services and automatic scanners.

And there is the conclusion of the lesson: step 2 cleans up your repository, not the world. That is why step 1 is the only non-negotiable one.

Solution 3:

mkdir /tmp/practice-signing && cd /tmp/practice-signing && git init -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

# 1. A new key just for the exercise
ssh-keygen -t ed25519 -f /tmp/signing-key -N "" -C "[email protected]"

git config --local gpg.format ssh
git config --local user.signingkey /tmp/signing-key.pub
git config --local commit.gpgsign true
git config --local tag.gpgSign true
# 2. Allowed signers
echo "[email protected] $(cat /tmp/signing-key.pub)" > /tmp/signers
git config --local gpg.ssh.allowedSignersFile /tmp/signers
# 3. One signed commit and one unsigned
echo "one" > app.js && git add . && git commit -m "feat: first signed commit"
echo "two" >> app.js && git commit -am "feat: unsigned commit" --no-gpg-sign
# 4. Telling them apart
git log --pretty="%h %G? %an %s"
b2c3d4e N Ana Ferrer feat: unsigned commit
a1b2c3d G Ana Ferrer feat: first signed commit

G = a good, verified signature. N = unsigned. There is also B (bad signature) and U (good signature, untrusted signer).

git show --show-signature a1b2c3d | head -5
commit a1b2c3d...
Good "git" signature for [email protected] with ED25519 key SHA256:...
# 5. A signed tag
git tag -s v1.0.0 -m "First version"
git tag -v v1.0.0
object a1b2c3d...
type commit
tag v1.0.0
...
Good "git" signature for [email protected] with ED25519 key SHA256:...
# 6. What happens when you rewrite
git rebase -i HEAD~2      # change "pick" to "reword" on the first one, save and edit the message
git log --pretty="%h %G? %an %s"
d4e5f6a N Ana Ferrer feat: unsigned commit
c3d4e5f G Ana Ferrer feat: first signed commit (rewritten)

The SHAs have changed. With commit.gpgsign true, Git re-signs the rewritten commits with your key. If that setting were switched off, or if you were rewriting other people's commits, all the signatures would be lost: the new commits would be yours and unsigned.

The relationship with step 2 of the leak procedure is direct and has to be anticipated: git filter-repo rewrites every affected commit, generating new objects. The original signatures do not survive, because a signature covers the exact content of a particular object and that object no longer exists. After cleaning up a secret:

  • The cryptographic traceability of authorship is lost for the whole rewritten history.
  • The signed tags of earlier versions stop verifying.
  • If the signature is an audit requirement, the incident and the rewrite have to be documented as part of the record, because the earlier cryptographic evidence cannot be reconstructed.

It is a real cost of the clean-up, and one more reason — on top of all the others — to invest in prevention: the barriers in section 4 are vastly cheaper than this procedure.

Conclusion

The essentials of this lesson:

  • A repository is the worst possible place for a secret: it is cloned in full, it is immutable by design, it is replicated with no control and it is trivial to search. Hence the idea that governs everything: the only action that neutralises a leaked secret is invalidating it.
  • A secret is anything that expires, is rotated or can be revoked, plus personal data and infrastructure information. When in doubt, it is a secret.
  • Correct management takes the values out of the repository: environment variables with an ignored .env and a versioned .env.example for small projects, a secret manager for anything that reaches production, and least privilege and short expiry always.
  • Detection has three barriers: the pre-commit hook (a help, not a control: --no-verify exists), the CI scan on a protected branch (that really is a control) and the periodic scan of the whole history.
  • If it has already leaked, the order is not negotiable:
    1. Rotate the credential immediately. It is the only thing that genuinely works, and it is the fastest.
    2. Rewrite the history with git filter-repo (or BFG), on a --mirror clone, with a backup taken first.
    3. Coordinate with the team: every clone becomes obsolete, everybody has to clone again and redo their branches; and forks are separate repositories that do not clean themselves up.
    4. Request the purge on the platform and assume that copies remain in closed PRs, comments, caches and forks.
  • git rm does not delete from the history. It only takes the file off the tip; the blob is still alive, reachable and present in every clone. It is worse than doing nothing, because it gives false security and points at where to look.
  • On authentication, closing off lesson 04-03: ed25519 SSH keys with a passphrase, ssh-agent with an expiry so that it is not a nuisance, and your system's credential manager instead of credential.helper store, which stores tokens in plain text. Never credentials in the remote's URL.
  • Signing commits and tags (GPG or, more simply, gpg.format=ssh) gives cryptographic traceability of authorship, because a commit's author field is free text. It guarantees who signed and that the content has not been altered; it does not guarantee that the code is correct or that the key is not compromised. And rewriting the history destroys the signatures.
  • Hygiene: least privilege for people too, periodic access reviews, well configured protected branches, --force-with-lease always, and a checklist before making a repository public.
  • And the underlying warning: in regulated environments, tell your security or compliance officer before acting. Notification deadlines start on detection, not when you finish cleaning up.

task-manager now has useful messages, a clean history, the right files treated as they deserve, and its secrets outside. One last matter of the module remains, far less dramatic but increasingly annoying: the repository has become slow. git status takes three seconds on Carla's machine, the clone weighs 900 MB even though the code takes up 4, and nobody quite knows why.

We diagnose it and fix it in lesson 08-06: Performance Tips.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved