Skip to content

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

A key token left visible inside a glass panel full of code blocks, next to a server box that keeps a matching key sealed inside.A key token left visible inside a glass panel full of code blocks, next to a server box that keeps a matching key sealed inside.

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.js
  • REACT_APP_ in Create React App
  • EXPO_PUBLIC_ in Expo and React Native apps built with it
  • VUE_APP_ in Vue CLI and GATSBY_ in Gatsby
  • PUBLIC_ 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.

bash
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/null

If 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.

app/api/chat/route.ts
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

If the browser sends the model name or 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:

  1. Create a new key in the provider’s dashboard and revoke the old one.
  2. Put the new key only in server-side environment settings, under a name without a public prefix.
  3. Check the provider’s usage page for traffic you do not recognize, and set a spending limit.
  4. 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.

Request early access

Tell us a little about your team and the repositories you want to audit. We onboard teams in small batches and will reply by email.

We use these details only to reply to your request. Privacy policy