How to check for secrets in a git repository

Run git grep -n -I -E 'AKIA[0-9A-Z]{16}' $(git rev-list --all) to search every commit rather than the working tree. A key removed three commits ago still sits in the old blob, so git grep HEAD finds nothing while the history scan prints the file, the line and the commit that holds it.

Why check this

A credential that reached a commit is published the moment the branch is pushed, and deleting the line does not take it back. Anyone with clone access reads it out of the old blob in one command. Run this before a repository changes visibility, and as a gate on the pull request that adds an integration.

The failure it prevents is the quiet one: a review approves the diff that removes a key, everyone treats the key as gone, and it stays readable until the credential is rotated.

Prerequisites

mkdir secretdemo && cd secretdemo && git init -q -b main
printf "export const config = {\n  region: 'eu-central-1',\n  accessKeyId: 'AKIAIOSFODNN7EXAMPLE',\n  secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',\n};\n" > config.js
git add -A && git commit -q -m "add s3 config"
printf "export const config = {\n  region: 'eu-central-1',\n  accessKeyId: process.env.AWS_ACCESS_KEY_ID,\n  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,\n};\n" > config.js
git commit -q -am "move credentials to environment variables"
printf '# demo service\n' > README.md && git add -A && git commit -q -m "add readme"

Steps

  1. Step 1.

    Scan the current commit. This is what a reviewer sees and it is the answer most people stop at.

    git grep -n -E 'AKIA[0-9A-Z]{16}' HEAD; echo "exit=$?"
    
    exit=1

    No output and exit code 1. git grep exits 1 when nothing matched, the same as grep.

  2. Step 2.

    Scan every commit reachable from every branch and tag.

    git grep -n -I -E 'AKIA[0-9A-Z]{16}' $(git rev-list --all)
    
    1961aa65cd7fe542d7c48ec9de1ed3164110d5cc:config.js:3:  accessKeyId: 'AKIAIOSFODNN7EXAMPLE',

    Three fields before the match: the commit, the path inside that commit, the line number. -I skips binary blobs, which otherwise flood the result with base64-looking noise.

  3. Step 3.

    Name the commits where that string entered and left.

    git log --oneline -S 'AKIAIOSFODNN7EXAMPLE' --all
    
    3a7c7b1 move credentials to environment variables
    1961aa6 add s3 config

    -S counts occurrences and reports each commit that changed the count, so the removal appears alongside the introduction. The second line is the commit to date the rotation from.

  4. Step 4.

    Read the old file straight out of the object database, with no checkout.

    git cat-file -p 99c091f
    
    export const config = {
    region: 'eu-central-1',
    accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
    secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
    };

    The blob id comes from the index 99c091f..286be46 line of git log -p. Anyone holding a clone can run this, which is why a deleted key is a rotated key or an exposed one.

  5. Step 5.

    Search for the assignment shape as well as the key shape. The regex in step 2 matched the access key id and missed the value beside it.

    git grep -n -I -i -E "(secret|token|password|api_?key)[a-z_]*\s*[:=]\s*[\"'][^\"']{8,}" $(git rev-list --all)
    
    1961aa65cd7fe542d7c48ec9de1ed3164110d5cc:config.js:4:  secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',

    The [a-z_]* after the keyword is what makes it hit. Without it the pattern requires secret to be followed by a colon, and secretAccessKey: never matches.

  6. Step 6.

    Collapse the duplicates. Run the same scan on a repository with real history, in this case the h2check repository at 82 commits.

    git grep -n -I -i -E "(secret|token|password|api_?key)[a-z_]*\s*[:=]\s*[\"'][^\"']{8,}" $(git rev-list --all) | cut -d: -f2- | sort -u | wc -l
    
    11

    The raw scan printed 334 lines. Eleven are distinct file, line and text triples; the rest are the same lines carried forward by later commits. cut -d: -f2- drops the commit id, which is the field that makes them look different.

  7. Step 7.

    Include commits that no branch points at any more.

    git grep -n -I -E "xoxb-[0-9]{10}" $(git rev-list --all --reflog)
    
    7d6096847718c0a237901366cb9be00da5ab78fa:notify.js:1:export const SLACK = "xoxb-4242424242-abcdefghijklmnop";

    The same pattern without --reflog exits 1 and prints nothing. The commit was undone with git reset --hard, so no ref reaches it and only the reflog still names it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Empty output, exit 1 | Nothing in the scanned commits matched | Widen the pattern, then confirm the clone is not shallow. | | A hit on a commit that is not HEAD | The value is in history and not in the current code | Rotate the credential. Removing the commit does not reach clones. | | The same line under many commit ids | One occurrence carried forward | Drop the commit field and sort unique before counting, as in step 6. | | A hit only under --reflog | An unreachable local commit holds it | It never left this machine if it was never pushed. Confirm against the remote. | | A hit on a file that is not in the working tree | The file was deleted | Check the deleting commit, then rotate anything it held. |

Common mistakes

Sign: A scan reports hundreds of hits and the team stops reading the output.Cause: git grep prints one line per commit that contains the match, not one line per occurrence. On the h2check repository the same eleven lines produced 334 hits across 82 commits. Cut the commit field and sort unique before anyone looks at a count.
Sign: A file that no longer exists turns up in the results.Cause: History holds deleted files. scripts/_smoke.ts is absent from the working tree and from HEAD in this repository, and its line still appears in the history scan, because the commit that deleted it did not delete the blob it pointed at.
Sign: A key that was committed and then undone with git reset is reported clean.Cause: git rev-list --all walks refs. A commit that reset dropped is reachable only through the reflog, so add --reflog. The reflog is local and expires, so this case says the secret may still be in a colleague's clone rather than that it is safe.
Sign: The scan passes and a scanner in CI later flags the same repository.Cause: Patterns find what they were written for. Step 2 matched an access key id and missed the secret beside it until step 5 widened the rule, and a random 40-character string in a variable called cfg matches neither. Dedicated scanners such as gitleaks and trufflehog carry maintained rule sets for exactly this gap, and neither is installed here. Treat this as the check you can run with git alone.

What to check next

FAQ

How do I check for secrets in a git repo without installing anything?

Use git grep with a commit list, as in step 2. Nothing beyond git is needed, and it ran in seconds on an 82-commit repository here. A dedicated scanner adds a maintained rule set, which a hand-written pattern does not have.

Does removing the file remove the secret?

No. The blob stays in the object database and git cat-file -p reads it back, as step 4 shows. Rewriting history with git filter-repo removes it locally, and every existing clone keeps its copy. Rotate the credential.

Why does the scan take so long on a large repository?

git rev-list --all expands to one argument per commit, and git searches each commit's tree. Limit the range with --since, or scan one branch, when a full pass is too slow for a hook.

What patterns should I start with?

The provider formats you use, plus the assignment shape from step 5. AKIA and 16 uppercase alphanumerics is an AWS access key id, xoxb- starts a Slack bot token, -----BEGIN opens a private key.

Does this find secrets in a shallow clone?

No. git clone --depth 1 fetches one commit, so git rev-list --all returns one id and the scan reports a clean repository. Run git rev-parse --is-shallow-repository first, and fetch with --unshallow before trusting the result.

Verified

Verified by Maks Vernygit 2.41.0.windows.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny