Why deleting alone never fixes it
A secret that has ever been committed stays reachable in git history even after the file gets deleted. This article explains why that is, how pre-commit hooks and CI scanners stop leaks before they happen, and which order matters after a real incident: rotate the credential first, then clean up the history.
Table of Contents
- 1. Why deleting a file does not remove a secret from git history
- 2. How secret scanners actually work: pattern matching and entropy
- 3. Pre-commit hooks: wiring gitleaks and trufflehog through husky or pre-commit
- 4. After a real leak: rotate first, clean up history second
- 5. .gitignore discipline for Magento and PHP projects
- 6. CI-side secret scanning: GitHub and GitLab
- 7. Rewriting history: git filter-repo, BFG Repo-Cleaner, and their limits
- 8. Practical checklist: a leaked Magento, AWS, or database credential
- 9. Git secret management compared directly
- 10. Summary
- 11. FAQ
1. Why deleting a file does not remove a secret from git history
Every git commit is uniquely identified by its hash, and that hash depends on the content of the commit as well as every parent commit. Deleting a file that contains a secret and committing the new state simply adds one more commit to the history. The previous commit, the one that still contained the file with the secret, remains fully intact and reachable through its hash at any time. Git is designed as a history, not a snapshot of current state: deleting in git almost always means adding a new state, not removing an old one.
Technically, every file version lives as its own blob object in the repository's object database, referenced by its content hash. As long as any commit, any branch, any tag, or even just the reflog points to that blob, it counts as reachable and git gc will never remove it. Running git log --all or pointing git show at the old commit directly returns the secret in plain text, no matter how many commits have happened since.
What makes this worse: once the repository has been cloned, forked, or cached in a CI pipeline, the full history including the secret already exists in multiple places outside your control. Deleting it afterward in your own remote repository changes nothing about those copies. The example below shows how easily a supposedly deleted secret can be recovered.
#!/usr/bin/env bash
# demo-recover-deleted-secret.sh - shows that a deleted file is still
# fully recoverable from an earlier commit
set -euo pipefail
# Find the commit that deleted app/etc/env.php
git log --all --diff-filter=D --summary -- app/etc/env.php
# Recover the exact file content from the parent of that deleting commit
git show <deleting-commit-sha>^:app/etc/env.php > recovered-env.php
# Every blob referenced by ANY commit on ANY branch stays in the object
# database until it becomes unreachable AND git gc --prune actually removes it
git cat-file -p <blob-sha> | head -n 5
2. How secret scanners actually work: pattern matching and entropy
Secret scanners like gitleaks and trufflehog rely at their core on two complementary detection strategies. The first is pattern matching with regular expressions against known formats: an AWS access key reliably starts with AKIA, a GitHub token with ghp_, a Magento Marketplace token follows a fixed hex format. These rules are precise, but blind to anything without a known format, such as self-assigned database passwords or internal API keys.
The second strategy is entropy analysis: randomly generated strings, typical of passwords and tokens, have noticeably higher Shannon entropy than natural language or ordinary source code. trufflehog uses this approach to also catch unknown secret formats, at the cost of a higher rate of false positives, for example hash values or harmless base64-encoded data. gitleaks combines its own entropy heuristics with an extensive, community-maintained rule library for common providers.
In practice this means: a good scanning setup uses both tools, or at least both strategies, checks not only the current working tree but optionally also the full history when first installed in an existing repository, and allows project-specific allowlists for known false positives instead of wearing developers down with constant false alarms.
3. Pre-commit hooks: wiring gitleaks and trufflehog through husky or pre-commit
A pre-commit hook runs locally on the developer's machine before a commit is even created, making it the earliest possible line of defense. The Python-based pre-commit framework manages hooks across a project through a single .pre-commit-config.yaml, automatically installs the required tools in isolated environments, and is activated once per clone with pre-commit install. gitleaks ships an official pre-commit hook definition that works without extra configuration.
In Node.js-heavy frontend setups, which already exist for the Hyva Tailwind build, husky is a common alternative: it wires git hooks through package.json and typically calls gitleaks protect --staged in .husky/pre-commit, scanning only the changes staged for the commit. That keeps the hook fast, because the entire history is not rescanned on every commit.
It is important that a pre-commit hook never be the only protection mechanism. It can be bypassed with --no-verify, only runs on machines where it was installed, and does not protect against commits made through the GitHub web interface or by a colleague without the hook enabled. It is a fast first hurdle, not complete protection, which is why section 6 covers the server-side complement.
# .pre-commit-config.yaml - scans every commit for secrets before it is created
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
- repo: https://github.com/trufflesecurity/trufflehog
rev: v3.74.0
hooks:
- id: trufflehog
entry: trufflehog git file://. --since-commit HEAD --fail
# Alternative for Node.js-heavy setups (e.g. Hyva Tailwind build) via husky:
# package.json: "prepare": "husky install"
# .husky/pre-commit: gitleaks protect --staged --redact
4. After a real leak: rotate first, clean up history second
When a real secret is discovered in a pushed commit, the order of the response matters more than its perfection. The first and only truly urgent step is to rotate the affected credential immediately, invalidating it and replacing it with a new one. That holds regardless of whether the commit was pushed a minute ago or a year ago, and regardless of how quickly the git history gets cleaned up afterward.
The reason is simple: once a commit has been pushed to a remote that is publicly reachable, or even just reachable by the whole team, it may already have been cloned, indexed by a bot, or picked up by automated scanners that continuously crawl public repositories for secrets. Documented cases show such scans finding and abusing pushed secrets within minutes. A history rewrite hours or days later is too late for that window.
History rewriting is still worthwhile, but as the second step: it prevents future clones or another look at the history from exposing the secret again, and it is part of a clean cleanup. Reversing the order, rewriting history first and rotating afterward, wastes valuable time during which the actual risk, the still-valid credential, remains unchanged.
5. .gitignore discipline for Magento and PHP projects
The most effective defense against git secret leaks is that the secret never enters the commit in the first place. In Magento projects, three files are especially exposed: app/etc/env.php holds database credentials, the crypt key, and sometimes cache backend credentials in plain text. .env files, as used by accompanying Node or PHP tools, often hold API keys and credentials for external services. auth.json holds the Composer repository credentials for the Magento Marketplace, whose compromise grants access to commercial licensed code repositories.
All three belong in .gitignore from the start of the project, not only after a leak has happened. Magento has shipped a sensible default .gitignore for several versions now that already excludes app/etc/env.php, but project-specific additions such as extra .env files or local Docker overrides must be added manually. A good test: git status after a fresh installation should never show any of these files as untracked or, worse, staged.
It is worth noting the difference between .gitignore and files already tracked: once a file is under version control, adding a .gitignore entry afterward does not stop it from continuing to be committed. In that case it must first be explicitly removed from the index with git rm --cached before the .gitignore entry takes effect, and even then it remains visible in the existing history, see section 1.
6. CI-side secret scanning: GitHub and GitLab
Pre-commit hooks only protect when they are installed locally and not bypassed. CI-side secret scanning closes exactly that gap because it runs server-side and does not depend on any individual developer machine's configuration. GitHub Secret Scanning is automatically active for public repositories and detects secrets from known providers through partner programs that can validate formats such as AWS, Stripe, or Slack tokens directly with the relevant provider, often before a human even reacts.
Private repositories require GitHub Advanced Security, or Push Protection specifically, which can block a push containing a detected secret server-side before it ever lands in the remote. GitLab offers a comparable mechanism with Secret Detection as part of the CI/CD pipeline, running as its own job and flagging or blocking merge requests with found secrets, depending on the project policy configuration.
For self-hosted GitLab instances or plain Bitbucket setups without a native solution, the same functionality can be rebuilt with gitleaks or trufflehog as a standalone CI job that fails the build on a finding and stores a structured report as an artifact. The example below shows what such a finding typically looks like in a CI job's report.
{
"Description": "AWS Access Key",
"StartLine": 42,
"EndLine": 42,
"StartColumn": 15,
"EndColumn": 55,
"Match": "AKIA************************",
"Secret": "AKIA************************",
"File": "app/etc/env.php",
"SymlinkFile": "",
"Commit": "a3f9c21e8b7d4f5a6c9e1b2d3f4a5b6c7d8e9f01",
"Entropy": 3.95,
"Author": "ci-bot@mironsoft.de",
"Date": "2026-06-02T09:14:33Z",
"RuleID": "aws-access-token"
}
7. Rewriting history: git filter-repo, BFG Repo-Cleaner, and their limits
When a history rewrite is genuinely necessary, for example because the secret is scattered across many commits over a long period, or compliance requires that no trace remain, git filter-repo and BFG Repo-Cleaner are the two common tools. git filter-repo is the officially recommended successor to git filter-branch, considerably faster and with a safer default configuration. It allows targeted removal of individual paths across the entire history in a single pass.
BFG Repo-Cleaner is tailored to the specific use case of secret removal and is correspondingly easier to use: --delete-files removes files by name pattern, --replace-text replaces specific text patterns such as a known password with a placeholder, across the entire history. Both tools effectively rewrite every affected commit hash, which means the entire subsequent history also gets new hashes.
The limits matter: a force push is mandatory after the rewrite because the old and new histories are incompatible. Every existing clone, every fork, every colleague's local copy still contains the old history with the secret until those copies are explicitly re-cloned. GitHub also caches old commit views for some time via direct commit URLs, even after a force push. A history rewrite cleans up your own remote repository, not the entire ecosystem that already had access.
8. Practical checklist: a leaked Magento, AWS, or database credential
A structured runbook significantly shortens response time in a real incident compared to an improvised reaction under stress. The order from section 4 applies without exception: rotation before history. For a Magento-specific scenario, that means concretely: reset database credentials in app/etc/env.php through the hosting provider or the cloud console, rotate the crypt key, which may require re-encrypting stored sensitive data, and immediately revoke and regenerate a compromised auth.json token in the Magento Marketplace account.
For AWS credentials, the create-then-delete order matters: create a new access key first, switch the application over to it, and only then delete the old key, to avoid downtime. In parallel, CloudTrail logs should be checked for unusual API calls in the period since the suspected leak, to determine whether the key was already abused.
After rotation comes communication: inform every team member that a force push is coming and a re-clone of the repository is required, document the incident, and where legally relevant, check whether a data protection notification obligation applies if personal data was affected. The script and the following PHP example below show a practical implementation of this order as well as automated protection against future incidents.
#!/usr/bin/env bash
# incident-response.sh - order matters: rotate first, rewrite history second
set -euo pipefail
echo "STEP 1: Rotate the leaked credential IMMEDIATELY (do this first, always)"
# Example: rotating an AWS access key
aws iam create-access-key --user-name deploy-bot
aws iam delete-access-key --user-name deploy-bot --access-key-id <OLD_KEY_ID>
echo "STEP 2: Confirm the old credential is truly dead"
aws iam list-access-keys --user-name deploy-bot
echo "STEP 3: Only now rewrite history and remove the secret"
git filter-repo --path app/etc/env.php --invert-paths --force
echo "STEP 4: Force-push and ask every collaborator to re-clone"
git push origin --force --all
git push origin --force --tags
echo "NOTICE: all clones and forks still contain the old secret in their local history"
<?php
declare(strict_types=1);
namespace Mironsoft\SecuritySuite\Console;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* CLI command that fails a deployment if secret-bearing files are tracked by git.
*/
class VerifyNoTrackedSecretsCommand extends Command
{
private const FORBIDDEN_FILES = ['env.php', 'auth.json', '.env'];
/**
* Checks whether any forbidden file is present in the current git index.
*
* @param InputInterface $input Console input, unused in this command.
* @param OutputInterface $output Console output for reporting results.
* @return int Exit code, 0 if clean, 1 if a forbidden file is tracked.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$process = new Process(['git', 'ls-files']);
$process->mustRun();
$trackedFiles = explode("\n", trim($process->getOutput()));
$violations = array_filter(
$trackedFiles,
static fn (string $file): bool => in_array(basename($file), self::FORBIDDEN_FILES, true)
);
if ($violations !== []) {
$output->writeln('<error>Forbidden secret file(s) tracked by git: ' . implode(', ', $violations) . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>No forbidden secret files found in the git index.</info>');
return Command::SUCCESS;
}
}
9. Git secret management compared directly
The following overview contrasts insecure behavior around git secrets with its secure counterpart, focused on the order of operations and the tools that make the practical difference.
| Risk / behavior | Insecure behavior | Secure countermeasure | Effect |
|---|---|---|---|
| File deletion | Simply deleting the file and committing again | git filter-repo/BFG plus immediate credential rotation | The secret disappears from history too |
| Response order | Rewriting history first, rotating afterward | Rotate immediately, rewrite history afterward | Closes the window for abuse |
| Prevention | auth.json/env.php with no .gitignore entry | Full .gitignore coverage from project start | The secret never enters the commit |
| Automation | No automated secret scanning | Pre-commit hook combined with CI secret scanning | Double protection, local and server-side |
| Team coordination | Force push without team communication | Coordinated force push with re-clone instructions for everyone | Prevents broken local repositories across the team |
The individual rows show a recurring pattern: prevention through .gitignore and automated scanning is always cheaper than cleanup after a leak, and within the response to a real leak, the order of rotation before history is strict. Tools like git filter-repo only solve the technical side of the problem, not the organizational communication within the team.
Mironsoft
Secret scanning setup, incident response, and history cleanup for Magento projects
Suspect a secret leak in your repository, or want to prevent one?
We set up pre-commit hooks and CI secret scanning in your pipeline, support the safe rotation of compromised credentials, and handle the clean removal of secrets from git history with git filter-repo.
Secret scanning setup
Set up gitleaks/trufflehog as a pre-commit hook and a CI job
Incident response
Immediate rotation of compromised Magento, AWS, and database credentials
History cleanup
Coordinated git filter-repo cleanup including team rollout
10. Summary
Git history is immutable, and that is exactly what makes deleting a file with a secret ineffective unless the history is actively rewritten. Pre-commit hooks with gitleaks or trufflehog stop most leaks before they even happen, but do not replace server-side CI secret scanning, which cannot be bypassed. Consistent .gitignore discipline for env.php, .env, and auth.json prevents most incidents before they occur in the first place.
If a real leak happens anyway, the order matters more than the perfection of the response: rotate the affected credential first, then clean up the history with git filter-repo or BFG Repo-Cleaner. A force push makes your own repository clean, but changes nothing about clones, forks, or cached views that already exist, which is why coordinated team communication has to be a fixed part of every cleanup.
Git secret leaks: the key takeaways
History is immutable
A deleted commit's content stays reachable through its old commit hash until it is actively rewritten.
Rotate before rewrite
Rotate the credential immediately, only clean up history afterward with git filter-repo.
Layer both defenses
Pre-commit hook locally, CI secret scanning server-side, neither one alone is enough.
.gitignore from day one
env.php, .env, and auth.json belong in .gitignore from the start of the project.