DEV Community

Veristria
Veristria

Posted on Originally published at keydrift.dev

gitignore is not a security control

A .env file in .gitignore tells you one thing: the file is not in your git history. It tells you nothing about whether the value inside it is currently readable by anyone who loads your site.

Those are different questions, and only the second one describes what an attacker actually does. Nobody clones your repo. They open the deployed bundle.

Where the value actually goes

A build reads .env and then makes a decision about each variable. The decision is usually made by a prefix.

Framework Inlined into client output
Next.js NEXT_PUBLIC_*
Vite VITE_*
Create React App REACT_APP_*
Nuxt NUXT_PUBLIC_*
Astro PUBLIC_*
SvelteKit PUBLIC_*

Those prefixes are not warnings. They are instructions to publish. A variable so named is compiled into JavaScript that ships to every visitor, by design, and it works exactly as intended.

The mistake is almost never that someone misunderstood the prefix. It is that a key was moved into one during a debugging session at 2am, the bug got fixed, and it never moved back.

Three places the value survives after you rotate

This is the part people miss. Rotating the key is necessary. It is not sufficient, because the old value is still sitting in artefacts nobody thinks of as artefacts.

1. Source maps. A minified bundle may not show a readable key. The .map file beside it usually does — that is its entire purpose. Shipping source maps to production is common and mostly harmless, right up until the moment it is not.

# what an attacker runs, and takes about four seconds
curl -s https://yoursite.com/_next/static/chunks/main-*.js \n  | grep -oE '(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}'
Enter fullscreen mode Exit fullscreen mode

2. Previous deploys. Every platform keeps old deployments addressable. A Vercel preview URL from three weeks ago serves the bundle from three weeks ago, containing the key from three weeks ago. Rotation does not reach backwards.

3. Build logs. CI output, deploy logs and provider dashboards retain output for weeks. One console.log in a failing build is enough, and failing builds are exactly when people add logging.

The check that means something

Stop asking whether the file is ignored. Ask whether the value is recoverable from what you shipped.

# fetch what the browser actually receives, and look in it
curl -s https://yoursite.com | grep -oE '/_next/static/chunks/[^"]+.js' \n  | sort -u | head -20 \n  | xargs -I{} curl -s "https://yoursite.com{}" \n  | grep -oE '[A-Za-z0-9_-]{32,}' | sort -u
Enter fullscreen mode Exit fullscreen mode

That is crude and it is still more informative than reading .gitignore, because it operates on the artefact rather than on the intent.

What to do about the ones that are already out

In order, because the order matters:

  1. Rotate first, investigate second. The window matters more than the root cause, and the root cause is still there in an hour.
  2. Check the provider's logs for use. Stripe, Supabase, OpenAI and most others will tell you whether the key was used and from where. "It leaked" and "it was used" call for very different responses.
  3. Delete or expire old deploy previews. Otherwise step 1 protected the future and nothing else.
  4. Then fix the prefix, move the call server-side, and add the check to CI.

The general principle is worth stating plainly, because it generalises past secrets: a control that operates on your source is not a control on what you ship. The build stands between them, and the build is where the interesting mistakes live.

Top comments (0)