Guide
Your LLM API key is probably in your frontend bundle
Why VITE_ and NEXT_PUBLIC_ variables end up in the browser, how to check a build for a leaked OpenAI or Anthropic key, and the server route that fixes it.
· 7 min read · By the vetkit team


You ask an assistant to add a chat box to your React app. It installs the OpenAI SDK, reads the key from import.meta.env.VITE_OPENAI_API_KEY, and the feature works on the first try. The problem is that it also works for anyone who opens the browser’s developer tools, finds the key in your JavaScript and starts sending requests on your account.
This is one of the most common mistakes we see in AI-assisted projects, and it is easy to miss in review because nothing looks wrong. The key is not hard-coded. It lives in an .env file that is correctly ignored by git. The code reads it from an environment variable, which is what everyone says you should do.
How it happens
Frontend build tools do not have environment variables at runtime. The browser runs a static bundle. So when your code reads import.meta.env.VITE_SOMETHING or process.env.NEXT_PUBLIC_SOMETHING, the bundler replaces that expression with the literal value while it builds. After the build, there is no variable anymore, just a string sitting in a JavaScript file that every visitor downloads.
The frameworks are clear about this. Next.js says public variables are inlined into any JavaScript sent to the browser. Vite only exposes variables with the VITE_ prefix to client code precisely so you do not leak the others by accident. The prefix is the opt-in. When an assistant picks VITE_OPENAI_API_KEY because the unprefixed name did not work in the browser, it has opted in for you.
There is a quieter variant too. Some starter templates copy the key into the bundle with Vite’s define option, for example 'process.env.GEMINI_API_KEY': JSON.stringify(...) in vite.config.ts. The variable has no public prefix, so it looks safe, but the effect is exactly the same.
The official SDKs try to stop this. The OpenAI Node SDK refuses to run in a browser unless you pass dangerouslyAllowBrowser: true, and its README explains why: it exposes your secret credentials in client-side code. Assistants sometimes add that flag to make an error go away. Other clients, such as generic HTTP calls or SDKs without a browser check, do not warn you at all.
Which variables are public
If a variable starts with one of these prefixes, assume its value is readable by anyone who loads your app:
VITE_in Vite (and frameworks built on it)NEXT_PUBLIC_in Next.jsREACT_APP_in Create React AppEXPO_PUBLIC_in Expo and React Native apps built with itVUE_APP_in Vue CLI andGATSBY_in GatsbyPUBLIC_in Astro and SvelteKit’s public env modules
These prefixes are fine for values that are meant to be public, like an analytics site ID or the URL of your own API. They are never fine for a provider secret: OpenAI, Anthropic, Google Gemini, Mistral, a Stripe secret key, a database service role key.
Check your build
The quickest test is to build the app and search the output for the key’s prefix. OpenAI project keys start with sk-proj-, Anthropic keys with sk-ant-, and Google API keys with AIza.
npm run build# Vite writes to dist/, Next.js static chunks live in .next/static/grep -rEo "sk-(proj|ant)-[A-Za-z0-9_-]{8}|AIza[0-9A-Za-z_-]{8}" dist .next/static 2>/dev/nullIf that prints anything, the key is in your bundle. Look at a deployed site the same way: open developer tools, go to the Sources or Network tab, and search the loaded scripts. If you can find it in thirty seconds, so can a script that crawls sites looking for exactly this.
The fix
Move the provider call to a server you control and let the browser talk to that server instead. In Next.js that is a route handler. In a Vite single-page app it can be a serverless function on the same host. The key keeps an unprefixed name, so the bundler never inlines it.
import OpenAI from 'openai'; // Server-only: no NEXT_PUBLIC_ prefix, never shipped to the browser.const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function POST(request: Request) { const user = await requireSignedInUser(request); // your auth check await rateLimit(user.id); // your limiter: per user, per minute const { message } = await request.json(); if (typeof message !== 'string' || message.length > 4000) { return Response.json({ error: 'Invalid message' }, { status: 400 }); } const response = await client.responses.create({ model: 'gpt-5-mini', input: message, max_output_tokens: 800, }); return Response.json({ reply: response.output_text });}The three details around the call matter as much as moving it. Without an auth check, your route becomes a free, anonymous proxy to the model, which is the same leak with extra steps. Without a rate limit, one signed-in user can still run up a large bill. Without an output cap, a single request can generate far more tokens than your feature needs.
Keep the model name and limits on the server too
max_output_tokens, a visitor can change them. Decide those values server-side and accept only the user’s message from the client.If the key already shipped
Removing the variable from the code does not help on its own. Old builds stay cached in browsers and CDNs, and the key may already be copied. Treat it as compromised:
- Create a new key in the provider’s dashboard and revoke the old one.
- Put the new key only in server-side environment settings, under a name without a public prefix.
- Check the provider’s usage page for traffic you do not recognize, and set a spending limit.
- Redeploy, then repeat the build search above to confirm the new bundle is clean.
If the key was ever committed to git, rotate it even if you deleted the file. Git history keeps the old value.
Catch it before it ships
This mistake is easy to automate away. A secret scanner catches keys that are committed to the repository. It cannot see a key that lives in an ignored .env file, though, so you also want a check that reads the code and notices a public variable being passed to an LLM client.
That second pattern is one of the LLM and agent security rules in vetkit’s full scans: it flags a value from a client-side environment variable (for example import.meta.env.VITE_* or process.env.NEXT_PUBLIC_*) that reaches an LLM SDK client in browser code, and explains how to move the call server-side. vetkit’s free Security Quick Scan covers committed secrets, known vulnerable dependencies and basic security posture. Neither replaces a quick look at your own build output, which is the most direct evidence you can get.