Stopping a service key from reaching the browser

Next.js inlines any environment variable prefixed NEXT_PUBLIC_ into the client bundle at build time. Vite does the same with VITE_, Create React App with REACT_APP_. That is the documented, intended behaviour: those prefixes exist to mark a value as public.

The failure is putting a secret behind one of them. The credential is real and correctly formatted; it is simply in the wrong build. Anyone can open devtools and read it.

Which keys are safe behind the prefix, and which are not

Safe — designed to be publicNot safe — grants server access
NEXT_PUBLIC_SUPABASE_ANON_KEYNEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYNEXT_PUBLIC_STRIPE_SECRET_KEY
NEXT_PUBLIC_FIREBASE_API_KEY
public by design; secured by rules
VITE_OPENAI_API_KEY
bills your account, no rules protect it

How to check your own project in one minute

# any browser-prefixed variable whose name suggests a secret
grep -rEn "(NEXT_PUBLIC_|VITE_|REACT_APP_)[A-Z_]*(SECRET|SERVICE_ROLE|PRIVATE|API_KEY|TOKEN)" \
  .env* src app lib 2>/dev/null

Then read every hit, because two of them will not be bugs:

The two cases that cost us a public apology

We were preparing security notices for public repositories, and we check every one by hand before sending. Both of these were wrong, and both are now pinned by a regression test written from the exact line:

1. A variable forced empty is the fix, not the bug

...(command === "build"
  ? { "import.meta.env.VITE_OPENAI_API_KEY": '""' }
  : {}),

That line removes the key from every production build. A six-line comment above it explains that Vite inlines VITE_* into the bundle. We would have told a team that understands this better than our scanner does that they have the bug they had already fixed.

2. Reading a variable in Node is not publishing it

module.exports = {
  openAIApiKey: process.env.VITE_OPENAI_API_KEY,
}

A translation tool's configuration. It runs in Node at build time. The VITE_ prefix misleads: the variable is never referenced by client code, so nothing reaches the browser. Any scanner that greps for the prefix will flag this, and be wrong.

The fix

  1. Rotate the key first. Assume it is compromised: the bundle is public and may be cached or archived. Cleaning history takes hours; the key is live for all of them.
  2. Move it to a variable without the prefix, read only from server code — a route handler, a server action, an API route.
  3. If the browser genuinely needs to reach the provider, proxy the call through your own backend, or use the publishable key, which exists for exactly this.

What we measure, and publish

MeasurementResult
First automated run over 249 public pull requests 150 findings, 79 CRITICAL — almost none real
Same population after four rounds of fixes 39 findings, 0–1 CRITICAL
Dependencies resolved live against PyPI and npm the live count
AI-hallucinated packages confirmed 0

That last row works against us: we built this around invented package names, measured it, and found the attack is rarer than the industry suggests. A package that does not exist makes pip install fail and CI go red, for free. We publish it because a vendor you can check is worth more than a vendor you have to believe.

Install on GitHub →

Last reviewed: 16 September 2026. Every figure on this page is our own measurement and is reproducible against our public endpoint.