DEV Community

Cover image for Your .env Is Not Where Your Secrets End Up: Scanning the Bundle You Actually Ship
Louis Wright
Louis Wright

Posted on

Your .env Is Not Where Your Secrets End Up: Scanning the Bundle You Actually Ship

This article was written with AI assistance and reviewed by the author.

In 2019, researchers at North Carolina State University went looking for API keys and private keys in public GitHub repositories. Their NDSS paper, "How Bad Can It Git?", found that secret leakage was pervasive, affecting over 100,000 repositories, and that thousands of new, unique secrets were leaked every day [1].

Most of the advice that followed focused on the repository: add .env to .gitignore, run a pre-commit scanner, never commit credentials. That advice is good. But there is a second place secrets leak that a clean repository does not protect you from: the JavaScript bundle you ship to every visitor.

How a secret walks out of the build

Modern frontend tools deliberately copy some environment variables into client code. Vite's documentation says it plainly: variables prefixed with VITE_ are exposed in client-side source code after bundling, and it warns you to avoid that prefix to prevent leaking variables to the client [2]. Next.js works the same way: to expose a variable to the browser it must be prefixed with NEXT_PUBLIC_, and those values are inlined into the JavaScript bundle during next build [3].

So the failure mode is not exotic. Someone needs a key "just for a quick test", gives it the public prefix (or pastes it straight into a config object), and the build faithfully writes the literal value into dist/assets/index-<hash>.js. The .env file never touches git. The secret still ends up on the public internet.

How a server secret reaches the browser: .env, build, deploy, leak

MITRE classifies this family of mistakes as CWE-798, Use of Hard-coded Credentials [4]. Once a key has shipped, deleting it from the next build is not enough. OWASP's Secrets Management Cheat Sheet notes that restarting an application does not revoke stolen credentials; they remain usable until the backing service expires or revokes them [5]. The only real fix is rotation.

Checking the artifact, not just the source

Source scanners look at what you wrote. The browser sees what the bundler produced. bundle-secret-guard is a small CLI that scans built frontend output (it auto-detects dist, build, out, .next/static, .output/public and .svelte-kit/output/client) and fails when secrets or API keys made it into the files you are about to deploy. It can also cross-check the values in your .env files, so a server-only value that appears literally in the bundle is flagged even if it does not match a known key format.

A demo build (with fake keys)

To be upfront: the project below is made up for this article, and every key in it is fake. The AWS pair is a widely used example key pair (note the EXAMPLE suffix), and the Stripe value is a placeholder shaped like a live key. The .env:

VITE_API_URL=https://api.example.com
STRIPE_SECRET_KEY=sk_live_51Hx9QkFakeDemoKey0000000000000000000000000000
Enter fullscreen mode Exit fullscreen mode

And a "built" bundle at dist/assets/index-3f9a1c.js where someone hard-coded the config:

const cfg={apiUrl:"https://api.example.com",stripe:"sk_live_51Hx9QkFakeDemoKey0000000000000000000000000000",aws:{accessKeyId:"AKIAIOSFODNN7EXAMPLE",secretAccessKey:"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}};
Enter fullscreen mode Exit fullscreen mode

Running npx bundle-secret-guard dist -e .env with version 1.1.1 gives this real output:

bundle-secret-guard output: 4 errors in the demo bundle

Four errors, each with a file, line and column, and the secret itself masked in the output:

  • a Stripe live secret key, matched by format;
  • the same value flagged again as a server-only env leak, because it matches STRIPE_SECRET_KEY from .env;
  • an AWS access key ID;
  • an AWS secret access key.

Every fix starts the same way: rotate the key, because anything that was deployed must be treated as leaked. Then it points to where the call belongs instead, such as a server or API route, a serverless function, or pre-signed URLs for AWS. The command exits with code 1, so a CI job stops before the deploy step.

Wiring it into CI

Run it after the build and before the deploy:

- run: npm run build
- name: Check bundle for secrets
  run: npx bundle-secret-guard@1 dist -e .env.production --sarif bundle-secrets.sarif
Enter fullscreen mode Exit fullscreen mode

Useful options:

  • --baseline <file> and --update-baseline to accept known findings (stored as hashes only) and fail only on new ones.
  • --entropy-scan for extra high-entropy string detection.
  • --gitleaks <file> to reuse existing gitleaks TOML rules.
  • --verify is opt-in and checks whether a key is live using the provider's own API. It is off by default.

The takeaway

The 2019 study showed how often secrets slip into source code [1]. Build tools add a second path, because public env prefixes are designed to be copied into client code [2][3]. Scan the artifact you actually deploy, and if a key ever ships, rotate it rather than just removing it [5].

References

  1. Meli, M., McNiece, M. R. and Reaves, B. (North Carolina State University). "How Bad Can It Git? Characterizing Secret Leakage in Public GitHub Repositories". NDSS Symposium, 2019. https://www.ndss-symposium.org/ndss-paper/how-bad-can-it-git-characterizing-secret-leakage-in-public-github-repositories/
  2. Vite. "Env Variables and Modes". Accessed Oct 2026. https://vite.dev/guide/env-and-mode
  3. Vercel. "Next.js Docs: Environment Variables". Accessed Oct 2026. https://nextjs.org/docs/app/guides/environment-variables
  4. MITRE. "CWE-798: Use of Hard-coded Credentials". Accessed Oct 2026. https://cwe.mitre.org/data/definitions/798.html
  5. OWASP. "Secrets Management Cheat Sheet". Accessed Oct 2026. https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The bundle scan is the right instinct, and it pairs with a second check for the same failure mode: “public” secrets come in two flavors, and only one of them is a mistake. A leaked VITE_/NEXT_PUBLIC_ service key or DB password is a bug. But the Supabase anon/publishable key is supposed to ship — it is in every page load by design. So a bundle scan finds it and flags it, which trains people to allowlist it and look away from the real risk: with the anon key shipped, RLS plus the table grants are the only thing between a visitor and your rows. A bundle check should therefore assert two things, not one:

  1. No server secret (service_role key, DATABASE_URL, Stripe secret) appears in dist/.
  2. The expected public key is present, and every table it can reach denies by default — RLS on, and a cross-user read returns nothing.

The second is what turns “the key leaked” from a scare into a known quantity: the key is public, but it can’t read anyone else’s data. A two-account probe asserts exactly that against the shipped key, and I keep one here if useful: github.com/cekuu35/supabase-rls-le...

Collapse
 
mrlouiswright profile image
Louis Wright •

Good distinction. A public key that is meant to ship and a server secret that leaked are very different findings, and treating them the same leads to alert fatigue. Failing the build on server secrets while checking that the public key is properly constrained by RLS is a sensible way to split it. Thanks for adding this.