Frontend build tools have a naming convention that quietly causes one of the most common secret leaks in modern web development: prefix an environment variable with VITE_ in a Vite project, or NEXT_PUBLIC_ in Next.js, and the value gets baked directly into the JavaScript bundle shipped to every visitor’s browser. It isn’t a bug — it’s documented, intentional behavior for exposing genuinely public config like analytics IDs. The problem is that developers reach for the same prefix when they just want an environment variable to “work,” without registering that “work” means “ship in plaintext to anyone who opens DevTools.”
Cyble Research has found thousands of GitHub repositories and live production sites leaking API keys this way, and misconfigured client-exposed cloud API keys have produced real damage — including at least one reported $82,000 cloud bill after a Google Maps key embedded in client-side code was scraped and abused. GitHub’s secret-scanning telemetry shows exposed credentials in public repos are typically found and used within about 60 seconds of being pushed. None of this requires a sophisticated attacker; it requires view-source: and a text search for the string _KEY.
Why this keeps happening
The mental model developers bring from server-side .env files is “environment variables are private by default.” Vite and Next.js invert that for a specific, prefixed subset — but the inversion is easy to miss because the variable still looks like every other entry in .env.
Vite’s own docs are explicit: variables prefixed VITE_ are “exposed in client-side source code after Vite bundling,” and the docs warn they “should not contain sensitive information such as API keys.” Next.js works the same way: non-NEXT_PUBLIC_ variables stay server-only in process.env, but anything prefixed NEXT_PUBLIC_ is “inlined into the js bundle… replacing all references to process.env.[variable] with a hard-coded value” at next build time.
The vulnerable pattern
// .env (Vite project)
VITE_OPENAI_API_KEY=sk-live-...
VITE_STRIPE_SECRET_KEY=sk_live_...
// src/lib/openai.js
const client = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY, // baked into bundle.js, readable by anyone
dangerouslyAllowBrowser: true,
});
export async function summarize(text) {
return client.chat.completions.create({
model: "gpt-5",
messages: [{ role: "user", content: text }],
});
}
// .env (Next.js project) — same mistake, different prefix
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
Anyone can open the deployed site’s DevTools, search the bundle for sk-live or sk_live, and get a fully usable, unrestricted secret key billed to your account. This is CWE-200 (exposure of sensitive information) with no exploit code required — the “attack” is reading a file the application shipped on purpose.
The fix: keep the secret on the server, proxy the call
The correct pattern is a backend-for-frontend (BFF) route that holds the real secret and the browser only ever talks to your own origin.
// .env — no public prefix, stays server-only
OPENAI_API_KEY=sk-live-...
// Next.js Route Handler: app/api/summarize/route.js
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req) {
const { text } = await req.json();
if (typeof text !== "string" || text.length > 4000) {
return Response.json({ error: "invalid input" }, { status: 400 });
}
const completion = await client.chat.completions.create({
model: "gpt-5",
messages: [{ role: "user", content: text }],
});
return Response.json({ result: completion.choices[0].message.content });
}
// Client code — no secret anywhere in this file or the bundle
export async function summarize(text) {
const res = await fetch("/api/summarize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error("summarize failed");
return res.json();
}
For a plain Vite SPA without a server runtime, the same principle applies via a small serverless function (Cloudflare Worker, Vercel/Netlify function, or a lightweight Express endpoint) that holds OPENAI_API_KEY unprefixed and proxies the request — the SPA never imports the secret at all.
Defense in depth beyond the fix
- Scope and restrict every key at the provider (HTTP referrer restrictions, IP allowlists, per-key rate limits, minimal permissions) so a leak is contained even if one slips through.
- Grep before you ship:
grep -r "VITE_\|NEXT_PUBLIC_" .env*and cross-check against a list of known-sensitive names before merging. - Scan built output, not just source — run
gitleaks detectortrufflehog filesystem ./distagainst the production build artifact, since that’s what actually reaches the browser. - Rotate immediately if a public-prefixed variable is ever found holding a real secret; treat it as already compromised, not “still safe because no one’s found it yet.”
- Add a CI check that fails the build if any
VITE_/NEXT_PUBLIC_variable name matches a deny-list pattern likeSECRET,PRIVATE,_KEY$, orTOKEN.
The lesson isn’t “don’t use these prefixes” — they exist for a reason, and public config like a Sentry DSN or a Google Analytics ID belongs there. The lesson is that the prefix is a publication mechanism, not a naming convention, and every environment variable needs a five-second gut check before it gets one: would I paste this value into the page source right now?