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 public | Not safe — grants server access |
|---|---|
NEXT_PUBLIC_SUPABASE_ANON_KEY | NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | NEXT_PUBLIC_STRIPE_SECRET_KEY |
NEXT_PUBLIC_FIREBASE_API_KEYpublic by design; secured by rules |
VITE_OPENAI_API_KEYbills 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
- 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.
- Move it to a variable without the prefix, read only from server code — a route handler, a server action, an API route.
- 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
| Measurement | Result |
|---|---|
| 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.
Last reviewed: 16 September 2026. Every figure on this page is our own measurement and is reproducible against our public endpoint.