DEV Community

Jordan Huang
Jordan Huang

Posted on

Your .env Is Not a Vault: Five Secret-Handling Myths and a Three-Place Scanner

A few weeks ago I put an API key in a .env file on a free server. Then I asked a dull question. Who else can read this file?

Nobody had checked. So I checked. The short answer was uncomfortable. More people can read it than I assumed.

This is a myth-busting FAQ, not a lecture. Every claim below is one I have heard, or said myself. Every claim gets a probe you can run.

I will not claim I invented secret handling. I did write the scanner at the end. It found two leaks in my own repositories, so it earns its place here.

Myth 1: Environment variables never touch disk

The claim: export API_KEY=... beats a file, because nothing persists.

The evidence: the variable lives inside the process. On Linux, that is a readable surface.

# what is this process actually holding?
PID=$(pgrep -f 'node server.js' | head -n1)
tr '\0' '\n' < /proc/$PID/environ \
  | grep -iE 'key|token|secret' \
  | sed 's/=.*/=<redacted>/'
Enter fullscreen mode Exit fullscreen mode

A container is no better. Anyone with socket access reads the same values:

docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $CID \
  | sed 's/=.*/=<redacted>/'
Enter fullscreen mode Exit fullscreen mode

Corrected model: environment variables keep secrets out of git. That is their one real win. They hide nothing from the machine itself.

Myth 2: Rewriting history removes the leaked key

The claim: git filter-repo plus a force-push cleans up the mistake.

The evidence: a force-push makes a commit unreachable, not gone. Reflogs, CI caches, forks, and local clones may keep it. Hosts often still serve unreachable commits by SHA for a while. I cannot promise a timeline, because retention policies differ per provider.

Corrected model: revoke first, rewrite second. The rewrite is cleanup. It is never the fix.

Myth 3: A private repo makes a committed key private

The claim: the repository is private, so the .env inside it is fine.

The evidence: count the copies instead. Laptop clones. CI logs from a set -x run. Fork-based pull requests. A zip someone downloaded in March. Your own ~/Downloads folder.

Corrected model: a commit is a publication. Private is a permission check, not a promise. Treat anything committed as public forever.

Myth 4: My frontend env var is safe because the name looks internal

The claim: INTERNAL_API_TOKEN is fine, because the name sounds scary.

The evidence: bundlers inline values at build time. Vite uses the VITE_ prefix. Next.js uses NEXT_PUBLIC_. Either way, the string lands in dist/. Just grep it:

grep -rIl 'YOUR_TOKEN_PREFIX' dist/ | head
Enter fullscreen mode Exit fullscreen mode

If that prints a filename, the token is public. There is no ambiguity here.

Corrected model: the browser cannot keep a secret. Give the browser a short-lived, scoped token. Keep the real key behind your own endpoint.

Myth 5: A free key is disposable, so hygiene can wait

The claim: it costs nothing, so who cares.

The evidence: a key is an identity. It links to your account, your quota, and your logs. Abuse from your key looks like you. Free tiers also tend to have thinner support when something goes wrong.

Corrected model: free keys need the same rotation habit. The cost is zero. The attribution is not.

The three-place scanner

I want one command and three checks. Working tree, git history, build output.

Save this as leakcheck.sh and run it from the repo root:

#!/usr/bin/env bash
# leakcheck.sh [BUILD_DIR] - look for secret-shaped strings in three places.
set -uo pipefail

PAT='(sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'
BUILD_DIR="${1:-dist}"

echo '== 1. working tree =='
grep -RInE "$PAT" . \
  --exclude-dir=.git --exclude-dir=node_modules --exclude-dir="$BUILD_DIR" \
  | head -n 20 || true

echo '== 2. every commit =='
for rev in $(git rev-list --all); do
  git grep -InE "$PAT" "$rev" -- 2>/dev/null
done | head -n 20

echo '== 3. build output =='
if [ -d "$BUILD_DIR" ]; then
  grep -RInE "$PAT" "$BUILD_DIR" | head -n 20
else
  echo "skip: no $BUILD_DIR"
fi
Enter fullscreen mode Exit fullscreen mode

Then run it:

chmod +x leakcheck.sh && ./leakcheck.sh dist
Enter fullscreen mode Exit fullscreen mode

Read the output as a smell test. It matches shapes, not entropy. Your internal key format is probably not in that regex. Add it, or the scan is theatre.

Why three places and not one

Most teams check the working tree only. That is the cheapest place, and the least likely to hide the older leak. The interesting hits live in history and in build output.

What the script cannot do

It does not read your issue tracker. It does not read screenshots. It does not know whether a key is scoped too broadly. It only answers one question: does this string shape appear where it should not?

A decision table for one key

Pick a row before you write code, not after the incident.

Location Survives restart Same-UID process can read Can reach git Good for
.env file yes yes yes, with git add -f local dev only
exported env var no yes, via /proc no one-off commands
systemd Environment= yes yes, via systemctl show yes, if the unit file is in the repo small single-tenant boxes
Docker --build-arg baked into the image yes no never for secrets
runtime fetch from a secret store no yes, in memory no anything shared or long-lived

A ten-minute rotation drill

Practise it before you need it.

  1. Create a second key beside the live one.
  2. Deploy the second key to the free box.
  3. Confirm real traffic works on the new key.
  4. Delete the first key at the provider.
  5. Watch logs for one hour for 401 responses.

If step 3 fails, you still hold a working key. That is exactly why the order matters.

Where a free model key and a free server change the calculus

Free hosting shifts the threat model a little. You usually get less control over log retention. You may share a host with other workloads. And you almost certainly cannot attach a hardware-backed secret store.

MonkeyCode offers free model access and a free server option. Both are operator-supplied claims, so read the current terms yourself. I am not going to quote quotas, hardware, or model names here. What I can do is say how I would use that setup.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

My rules for that kind of box:

  • one key per box, never one key for everything;
  • the model key stays server-side and never enters dist/;
  • rotate on a calendar date, not on an incident;
  • assume the box logs more than you think.

None of this is exotic. It is the boring part that survives a bad week.

Who should not use this approach

  • Teams with audit requirements. You want short-lived credentials from a real secret manager.
  • Anyone shipping into a regulated environment. A regex scanner is not a control.
  • Repositories with hundreds of thousands of commits. The history loop is too slow to be a habit.

Limitations I hit

  • Shape regexes miss custom formats and produce false positives.
  • History scanning is slow, because it walks every revision.
  • The scanner cannot see secrets pasted into issues, screenshots, or chat.
  • It says nothing about scope. A clean scan can still hide an over-broad key.

Closing

Secrets leak through boring paths. Env vars, force-pushes, old zips, and dist/. None of that feels dramatic. That is precisely why it works.

Run the scanner once. Then ask a better question. Which key would hurt most if it appeared in a log tomorrow? Rotate that one first.

If you add a pattern for your own key format, I would like to see it. That detail is usually the missing piece.

Top comments (0)