Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français
Lire en français

Secrets in code and Git history

By updated

Improve this page

A developer "temporarily" commits a password to run a test. They remove it in the next commit. The problem: Git history keeps everything. Three years later, an attacker scans public repositories and finds that secret, still valid.

But Git is only the starting point. Once pushed, the secret replicates into clones, forks, CI runners, artifacts, backups and analysis systems. This guide covers the three layers you need: prevent, detect and respond.

What you will learn

  • Why deleting a commit is not enough, and what is truly irreversible
  • How the ecosystem around Git multiplies the leak surface
  • The detection tools (Gitleaks, TruffleHog) and push protection
  • The prevention measures (hooks, CI, forge)
  • How to respond and clean the history without fooling yourself

Prerequisites

The secrets management hub (in French) places this guide among the others: static secrets, lifecycle, machine identity and vaults.

Why deleting a commit is not enough

Git history is immutable (by default)

When you commit a secret and then delete it:

Terminal window
# Commit 1: add the secret
git add .env
git commit -m "Add config"
# Commit 2: remove the secret
git rm .env
git commit -m "Remove config"

The file no longer appears in HEAD, but it still exists in the history:

Terminal window
# The secret is still accessible
git show HEAD~1:.env
# DB_PASSWORD=SuperSecret123

Even after a rebase or a force push

Terminal window
# Attempt to rewrite history
git rebase -i HEAD~2
# Drop the commit that contains the secret
git push --force

A force push rewrites the history of the remote repository, but it does not erase the secret everywhere:

  • Existing clones keep the old history
  • Forks keep the old history
  • Backup systems keep the old history
  • CI caches potentially keep the history

The reflog keeps everything (locally)

Even after a rebase, the local reflog keeps a trace:

Terminal window
git reflog
# abc1234 HEAD@{0}: rebase: ...
# def5678 HEAD@{1}: commit: Add config # ← The secret is still there

By default, Git keeps reflog entries for 90 days, and 30 days for those pointing to commits that have become unreachable, such as the commit dropped by a rebase (gc.reflogExpire and gc.reflogExpireUnreachable). On the other developers' machines, the old history stays intact.

Git is only the starting point of the leak

A committed secret does not disappear with a simple git rm. Git history is distributed: every clone, every fork keeps a complete copy. But the problem goes well beyond the repository itself.

Once the secret is pushed, it can be replicated into:

  • developer clones (each one holds a complete copy)
  • forks (including those of former collaborators)
  • CI/CD runners (which clone the repository on every pipeline)
  • build caches (Docker layers, npm cache, etc.)
  • pipeline artifacts (logs, reports, archives)
  • forge backups (GitHub, GitLab, Gitea)
  • mirrors and automatic replicas
  • analysis systems connected to the repository (SonarQube, Snyk, etc.)
  • temporary indexing by the forge (GitHub search, events API)
SurfacePersistenceCleanable?
Git history (origin repository)PermanentYes, with git-filter-repo
Developer clonesUntil re-clonedRequires coordination
ForksPermanentNo, outside your control
CI/CD runnersJob duration (or cache)Manual purge
Pipeline artifactsDepends on retentionManual deletion
Forge mirrors / backupsVariableDepends on the platform
Temporary indexingA few hoursAutomatic
Third-party analysis systemsVariableDepends on the service

The Cleanable? column separates what is irreversible (forks, external clones) from what you can still control.

Who scans public repositories?

Two kinds of actors need to be told apart:

Defensive detection (to protect you):

  • GitHub Secret Scanning: detects known patterns after the commit and alerts the maintainer
  • GitHub Push Protection: blocks the push before it enters the repository (see the dedicated section)
  • Security researchers: scans with TruffleHog or Gitleaks

Opportunistic exploitation (to attack you):

  • Malicious bots: scan public GitHub events in real time and exploit the keys they find automatically

On a public repository, some keys can be detected and exploited very quickly, sometimes within minutes.

Commonly exposed secret types

Some secret patterns are detected automatically by scanners. Malicious bots target cloud keys first, because they give immediate access to paid resources.

TypePatternRisk
AWS Access KeyAKIA... (20 characters)Full cloud access
GitHub PATghp_...Access to repositories
Stripe API Keysk_live_...Financial transactions
JWT SecretVariableToken forgery
Private Key-----BEGIN RSA PRIVATE KEY-----Identity theft
Database URLpostgres://user:pass@hostDatabase access
.npmrc / .pypirc_authToken=...Package publishing
docker/config.json"auth": "base64..."Push to a registry
kubeconfigtoken: or client certificateKubernetes cluster access
Terraform Cloud token*.atlasv1.*Infrastructure management
Client certificate-----BEGIN CERTIFICATE-----mTLS authentication

Compromise scenarios

Scenario 1: the AWS key exploited within minutes

T+0min : Developer pushes a commit with an AWS key
T+2min : Bot scans the public repository through the GitHub API
T+3min : Bot extracts the key and launches EC2 instances to mine
T+5min : AWS bill explodes
T+1h : Developer notices the problem

Real cost: thousands of euros in cloud billing.

Scenario 2: the forgotten "temporary" secret

T-3years : Secret committed for a test
T-3years : Secret removed in the next commit
T : Attacker scans the history
T+1h : Secret still valid, production access compromised

Nobody had revoked the secret: it had only disappeared from the current code. A scan of the files on disk, gitleaks dir, sees nothing anymore; a scan of the history, gitleaks git, finds it in the original commit. That is exactly what the attacker does.

Scenario 3: the former employee's fork

T : Private repository with a secret in its history
T : Employee leaves the company, keeps their fork
T+6m : The former employee still has access to the secret

Even after cleaning the history of the origin repository, the fork keeps its own complete copy. That is why revoking the secret is the only reliable action.

Preventing leaks

Prevention relies on several complementary layers. None of them is enough on its own.

The diagram follows a commit from the workstation to the main branch: each barrier catches what the previous one let through, and the only one that cannot be bypassed from the workstation is the blocking CI scan.

Path of a commit to the main branch: Gitleaks pre-commit hook on the workstation, forge push protection, then a blocking CI scan on the pull request. A --no-verify, a merge or an automation skip the hook and go straight to push protection.skips the hookCommit on theworkstationPre-commit hook(Gitleaks)Forge pushprotectionBlocking CI scanon the PRMainbranch--no-verify, merge,automation
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Pre-commit hooks (first barrier)

Local hooks block the commit when a secret is detected:

.pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
Terminal window
# Installation
pip install pre-commit
pre-commit install
# Test
git commit -m "Test"
# Gitleaks automatically scans the modified files

Custom Gitleaks configuration

Two traps await anyone writing their first .gitleaks.toml, and both were measured on Gitleaks 8.22.1 and 8.30.1. The allowlist paths are regular expressions, not file patterns: a pattern such as **/*_test.go makes Gitleaks crash on load (panic: regexp: Compile), and the hook and the CI with it. Above all, a file that declares its own [[rules]] replaces the default rules, unless it carries [extend] useDefault = true: without that line, the GitHub token in the test repository was no longer detected, and only the custom rule remained active.

.gitleaks.toml
[extend]
useDefault = true # keep the default rules in addition to yours
[allowlist]
paths = [
'''test/fixtures/''', # regular expressions, not globs
'''_test\.go$''',
]
[[rules]]
id = "custom-api-key"
description = "Custom API Key"
regex = '''custom_api_key\s*=\s*['"]([^'"]+)['"]'''

Forge push protection

GitHub offers push protection, which scans the content at push time and blocks the upload when a known secret is detected. It is a very strong prevention building block, different from after-the-fact secret scanning:

MechanismWhenAction
Push protectionAt push timeBlocks the upload
Secret scanningAfter the commitAlerts the maintainer

Benefits of push protection:

  • Acts before the secret enters the repository
  • Covers known patterns (cloud keys, forge tokens, etc.)
  • Complements local hooks (protects even without pre-commit)

Limits:

  • Only detects the forge's known patterns
  • A developer can force the push through (with a justification)
  • Does not replace CI scans of the full history

For organisations, GitHub also lets you define custom patterns for secret scanning and push protection, tailored to internal secret formats.

.gitignore: reducing common mistakes

# Secrets and local configuration
.env
.env.*
*.key
*.pem
*.p12
*.jks
secrets/
credentials/
# Package registries
.npmrc
.pypirc
# Docker
docker/config.json
# Kubernetes
kubeconfig
*.kubeconfig
# IDE
.idea/
.vscode/settings.json
# Logs that may contain secrets
*.log
logs/

Detecting secrets

Gitleaks

Gitleaks is a fast, rule-based scanner (regex). It is particularly well suited to pre-commit hooks and CI integration.

Terminal window
# Scan the full history (all commits)
gitleaks git --verbose
# Scan only the files present on disk
gitleaks dir --verbose

The difference between the two commands is at the heart of this guide. On a repository where the secret was removed from the code but remains in an earlier commit, gitleaks dir finds nothing and gitleaks git finds it: the "fixed" secret never left the history. Since version 8.19, these two subcommands replace gitleaks detect, which still works but is no longer listed in the help.

Strengths:

  • Fast, scans large repositories in seconds
  • Preconfigured patterns, covers the most common formats
  • Easy to integrate, pre-commit, GitHub Actions, GitLab CI

TruffleHog

TruffleHog goes further with active verification: it tests whether the secret it found is still valid by trying to use it. This lets you prioritise the real active secrets and set aside false positives or secrets that were already revoked.

Terminal window
# Scan with active verification
trufflehog git file://. --only-verified
# Output
Found verified result
Detector Type: AWS
Raw result: AKIAIOSFODNN7EXAMPLE
Commit: abc1234
Email: dev@company.com

Strengths:

  • Active verification, tells an exploitable secret apart from a false positive or an already revoked secret
  • Entropy-based detection, spots secrets that do not follow a known pattern
  • Classification, groups the results by type and by risk

Blocking CI scan

The CI scan is the last barrier before the code reaches the main branch. It must block the PR when a secret is detected.

name: Secret Scan
on: [push, pull_request]
permissions: {}
jobs:
gitleaks:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

CI best practices:

  • Pin actions by SHA, avoid mutable tags
  • Scan the diff on PRs, fast, targets the changes
  • Scan the full history periodically, catches old secrets
  • Block the PR on detection, no allow_failure: true

Three tools fill this role: Gitleaks, the course's reference implementation, TruffleHog (in French), which verifies whether a secret it finds is still active, and detect-secrets (in French), which works with a baseline of accepted false positives.

Responding to a secret leak

  1. Revoke immediately

    Change the secret before cleaning the history. An attacker may already have copied it.

  2. Assess the impact

    How long was the secret exposed? What access does it grant? Is the repository public or private?

  3. Look for malicious use

    Check the audit logs of the service concerned (CloudTrail for AWS, audit logs for GitHub, etc.).

  4. Clean the history

    See the next section on git-filter-repo.

  5. Purge the artifacts

    • Delete the CI/CD artifacts that contain the secret
    • Empty the build caches (Docker, npm, etc.)
    • Ask collaborators to re-clone the repository
  6. Prevent a recurrence

    Install the pre-commit hooks and the CI scans, and enable push protection.

Cleaning the history: what it does, and what it does not

git-filter-repo is the recommended tool to rewrite history (it replaces the obsolete git filter-branch).

What git-filter-repo does

Terminal window
# Remove a file from the whole history
git filter-repo --invert-paths --path config/secrets.yml
# Replace a pattern across the whole history
git filter-repo --replace-text expressions.txt

git-filter-repo removes the origin remote after the rewrite, to prevent an accidental push: measured, git remote is empty after the operation. You therefore have to add it back before pushing the rewritten history.

Terminal window
# Add back the remote removed by git-filter-repo
git remote add origin <repository-url>
# Force push on all branches
git push --force --all
git push --force --tags

What the cleanup does NOT do

Rewriting the history of the origin repository is necessary, but not enough:

SurfaceCleaned automatically?
Origin repositoryYes (after force push)
Developer clonesNo, each one must re-clone
ForksNo, outside your control
CI/CD artifactsNo, manual deletion
Build cachesNo, manual purge
Forge backupsNo, depends on the platform
Third-party analysis systemsNo, depends on the service

Consequence: cleaning the history is useful for repository hygiene, but revoking the secret remains the only truly reliable action. If a fork or an external clone captured the secret, no rewrite on your side will erase it.

Troubleshooting

The problems below come up when setting up the controls or cleaning a repository. Three of them were reproduced in a container: the Gitleaks configuration that crashes, the one that switches off the default rules, and git-filter-repo refusing to run.

SymptomLikely causeSolution
Gitleaks reports false positivesPattern too broad or test fileAdd the path, as a regular expression, to [allowlist] in .gitleaks.toml
git-filter-repo refuses: "does not look like a fresh clone"The repository is not a fresh clone: history in the reflog, or modified filesWork on a fresh, clean clone (git clone --no-local), or force with --force knowingly
Gitleaks crashes on startup: panic: regexp: CompileAn allowlist path written as a glob (**/*_test.go)Write the paths as regular expressions (_test\.go$)
No more alerts after adding a custom rule[[rules]] without [extend] replace the default rulesAdd [extend] useDefault = true to .gitleaks.toml
Secret detected but already revokedNo filtering by validityUse trufflehog --only-verified to prioritise
Pre-commit hook bypassedDeveloper used --no-verifyAdd a blocking CI scan as a safety net
Push protection bypassedDeveloper forced the push throughCheck the justifications in the forge logs
Collaborators get errors after a force pushCommit SHAs were rewrittenAsk the team to re-clone the repository

Key points

  1. As soon as a secret reaches Git, it is compromised, revocation is the only reliable action
  2. Git is only the starting point, the secret spreads into clones, forks, CI runners, artifacts and backups
  3. Revoke before cleaning, cleaning the history does not erase external copies
  4. Three prevention layers, pre-commit hooks + push protection + blocking CI scan
  5. No layer is enough on its own, hooks can be bypassed, push protection only covers known patterns, CI only protects after the push
  6. Cleaning the history has its limits, git-filter-repo rewrites the repository, but not the forks or the external caches

Knowledge check

Check that you have the essentials of this guide. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn