How to check for secrets in environment variables
Read four places, not one. git ls-files -z | xargs -0 grep -nIE 'SECRET|TOKEN|sk_(test|live)_' lists what the repository carries, cat dist/app.js shows what the bundler inlined for the browser, the saved test log shows what CI printed, and git log -p -S '<value>' finds a key a later commit deleted.
Why check this
Run this when a test environment changes hands: before a repository is opened to contractors, before CI logs become public artifacts, and after any incident where a key was rotated. The failure it prevents is narrow and common. A provider sandbox key sits in a committed .env, the sandbox holds a copy of production customers loaded there for testing, and anyone with read access to the repository can call the provider as your service.
A secret that is present is not the same as a secret that is exposed. Present means the value exists on disk where the test run needs it. Exposed means a party outside the team can reach it: a tracked file, a browser bundle, a CI log artifact, a recorded cassette in a public repository, or an old commit. The checks below separate the two, because a .env on a developer laptop is present and a .env in git ls-files is exposed.
One more thing to settle early: a test key is a real key. It authenticates to a real provider account, with real rate limits and, in most sandboxes, a copy of customer records someone loaded for testing. Treat sk_test_ the way you treat sk_live_.
Prerequisites
- Git 2.41 or later. The history search uses
git log -S, which searches content, not commit messages. - Node 22 for the test run, and a bundler to produce the artifact the browser gets. The runs below used esbuild 0.28.2.
- A fixture repository, so nothing here touches a real credential. Every value in it is marked
FAKE.
secrets-demo/
.gitignore node_modules/ , dist/ , ci.log , .env.local
.env committed, holds the planted keys
.env.example same names, empty values
src/app.js reads process.env.ANALYTICS_WRITE_KEY
tests/checkout.test.mjs
fixtures/cassette-checkout.json
# .env
NODE_ENV=test
API_BASE=http://127.0.0.1:9720
STRIPE_SECRET_KEY=sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb
SESSION_SECRET=FAKE_sess_7c1d9a4b6e2f8103
// src/app.js
const analyticsKey = process.env.ANALYTICS_WRITE_KEY;
export function track(event) {
return fetch('/t', { method: 'POST', headers: { 'x-write-key': analyticsKey }, body: event });
}
// tests/checkout.test.mjs prints the key in a debug line
console.log('[debug] calling provider with key=%s', process.env.STRIPE_SECRET_KEY);
// fixtures/cassette-checkout.json records the request header
{ "headers": { "authorization": "Bearer sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb" } }
The repository has two commits: one that adds every file, one that runs git rm .env and removes it again.
Steps
- Step 1.
Ask git which environment files it tracks, rather than which ones exist.
for f in .env*; do git ls-files --error-unmatch "$f" >/dev/null 2>&1 && t=yes || t=no git check-ignore -q "$f" && i=yes || i=no printf '%-14s tracked=%-4s gitignored=%s\n' "$f" "$t" "$i" done.env tracked=yes gitignored=no .env.example tracked=yes gitignored=no - Step 2.
Grep every tracked file for key names and key shapes.
git ls-files -z | xargs -0 grep -nIE '(SECRET|TOKEN|PASSWORD|API_?KEY|Bearer [A-Za-z0-9_-]{16,}|sk_(test|live)_[A-Za-z0-9_]+)'.env:3:STRIPE_SECRET_KEY=sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb .env:4:SESSION_SECRET=FAKE_sess_7c1d9a4b6e2f8103 .env.example:3:STRIPE_SECRET_KEY= .env.example:4:SESSION_SECRET= fixtures/cassette-checkout.json:5: "headers": { "authorization": "Bearer sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb" } tests/checkout.test.mjs:4: const key = process.env.STRIPE_SECRET_KEY;Four of those six lines are names with no value behind them. Two carry a value.
- Step 3.
Build the artifact the browser receives, with the environment the build machine has.
esbuild src/app.js --bundle --format=esm --minify --define:process.env.ANALYTICS_WRITE_KEY='"wk_FAKE_9a71c4f0d2b8"' --outfile=dist/app.jsdist\app.js 131b Done in 11ms - Step 4.
Read the built file. The bundler resolved the variable at build time.
cat dist/app.jsvar t="wk_FAKE_9a71c4f0d2b8";function r(e){return fetch("/t",{method:"POST",headers:{"x-write-key":t},body:e})}export{r as track}; - Step 5.
Run the suite the way CI runs it and keep the log.
node --env-file=.env --test tests/checkout.test.mjs | tee ci.logTAP version 13 # [debug] calling provider with key=sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb # Subtest: checkout sends the key ok 1 - checkout sends the key … # pass 1 # fail 0 - Step 6.
The committed
.envwas deleted in the second commit. Search the working tree for the value.grep -rIn 'sk_test_FAKE' . --exclude-dir=.git --exclude-dir=dist --exclude=ci.log./fixtures/cassette-checkout.json:5: "headers": { "authorization": "Bearer sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb" } - Step 7.
Search the history for the same value instead.
git log --oneline --all -S 'sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb' -p -- .env0bfd613 remove the committed .env diff --git a/.env b/.env deleted file mode 100644 index 40858a6..0000000 --- a/.env +++ /dev/null @@ -1,4 +0,0 @@ -NODE_ENV=test -API_BASE=http://127.0.0.1:9720 -STRIPE_SECRET_KEY=sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb -SESSION_SECRET=FAKE_sess_7c1d9a4b6e2f8103 bd920f4 checkout service with test env diff --git a/.env b/.env new file mode 100644 index 0000000..40858a6 … +STRIPE_SECRET_KEY=sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb +SESSION_SECRET=FAKE_sess_7c1d9a4b6e2f8103
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| .env tracked=yes | The file is in the repository, not only on disk | Remove it from the index, add it to .gitignore, rotate the key |
| .env.example:3:STRIPE_SECRET_KEY= | A name with no value | Nothing. This is the file that is supposed to be committed |
| A key literal inside dist/app.js | The bundler inlined it and every visitor gets it | Move the call server side. Rotate, because the value is already public |
| A key in ci.log | Every job artifact and every log viewer holds it | Redact at the logger, not at the test |
| A hit from git log -S after deletion | The blob is still reachable by hash | Rotate first, rewrite history second |
The deletion commit in step 7 is the part worth reading twice. git rm records the old content as a removal, so the diff that removes the secret prints the secret. The blob keeps its hash, 40858a6 here, and git cat-file -p 40858a6 returns the four lines to anyone who has the repository. Deleting a file is not deleting a value, which is why rotation comes before history rewriting rather than after it.
Step 6 makes the second half of the point. The working tree has no .env, and the recorded cassette still carries the key in an authorization header, because the recorder captured the request as sent.
Common mistakes
What to check next
- How to check for secrets in a git repository: the history side of this check, with the rewriting options.
- Sensitive data in logs: stop the log line at the logger so step 5 comes back empty.
- How to check if env variable exists: the same variables from the other direction, before a run starts.
- How to check config drift between environments: finds the key that exists in one environment file and not the others.
- How to check logs for pii: the same log artifact, read for customer data instead of credentials.
FAQ
How do I find leaked environment variables in a codebase?
Scan what the repository publishes rather than what the directory holds. git ls-files for tracked files, the build output for inlined literals, saved CI logs for printed values, and git log -S for deleted ones. A plain recursive grep of the working tree misses the last three.
How do I detect secrets in code without a scanning service?
Two patterns cover most of it: variable names holding SECRET, TOKEN, PASSWORD or KEY, and vendor prefixes such as sk_test_, ghp_ or AKIA. Step 2 combines both. High entropy alone reports hashes and minified identifiers as findings.
Is a value in .env.example a finding?
No, as long as the value is empty. Step 2 matches .env.example on the name, and the line ends at the equals sign. An example file with a working default is a finding, and it is the usual way one gets committed.
Does adding .env to .gitignore fix a committed file?
No. .gitignore is consulted for untracked files, so a tracked .env keeps being tracked. Step 1 prints both columns for that reason: tracked=yes gitignored=yes is possible and means the ignore rule arrived too late.
Verified
Verified by Maks Vernygit 2.41.0.windows.2node 22.23.2esbuild 0.28.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.
Related on this site
intermediate12 minpublished updated Maks Verny