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

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

  1. 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
  2. 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.

  3. 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.js
    
      dist\app.js  131b
    
    Done in 11ms
  4. Step 4.

    Read the built file. The bundler resolved the variable at build time.

    cat dist/app.js
    
    var t="wk_FAKE_9a71c4f0d2b8";function r(e){return fetch("/t",{method:"POST",headers:{"x-write-key":t},body:e})}export{r as track};
  5. 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.log
    
    TAP 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
  6. Step 6.

    The committed .env was 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" }
  7. Step 7.

    Search the history for the same value instead.

    git log --oneline --all -S 'sk_test_FAKE_51QwErTy8UiOpAsDfGhJkLzXcVb' -p -- .env
    
    0bfd613 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

Sign: The scan is clean, and the key is still in the front end bundle.Cause: A bundler resolves process.env at build time and writes the literal into the artifact. Nothing in the source tree contains the value, so a source grep cannot find it. Grep the build output, not the sources.
Sign: The .env file is gone from the working tree and the scanner still reports a finding.Cause: git rm removes the file from the next commit, not from the ones before it. The blob stays reachable by hash and by git log -S until the history is rewritten and the old objects expire.
Sign: The team treats a sk_test_ key as harmless.Cause: A sandbox key authenticates to a real provider account. Test environments are routinely loaded with copies of production records, so the sandbox holds real data even though the payments are not real.
Sign: A passing test leaked the key.Cause: The debug line runs on success as well as on failure, and CI keeps the log as a build artifact. Pass or fail has no bearing on what the log printed.

What to check next

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.

intermediate12 minpublished updated Maks Verny