Your API keys are in your frontend bundle

$ cat blog/api-keys-in-frontend-bundles.md
✓ vibe coding
✓ AI-generated code
✓ secrets
✓ frontend
10 min read
$ 

You asked your AI tool for a chat feature. Twenty seconds later it works, and the code is a React component that calls the LLM provider directly with an API key. It's the shortest path to a working demo, and it ships your key to every visitor.

This is the most common way secrets leak from modern web apps, and it's easy to miss because nothing looks wrong. The app runs, the key is "in an env file", and the file is in .gitignore. The key is still public.

Client code is public code

The rule is simple: anything that runs in the browser can be read by whoever opens the page. Minifying doesn't hide it, and neither does obfuscation. A user can open DevTools, read the JavaScript, and watch every request your app makes, including its headers and bodies. If a secret is needed to make a request from the browser, the browser has the secret.

So the question to ask about any key isn't "is it in git?" It's "does this value ever get sent to the browser?"

How env prefixes put values in the bundle

Frontend frameworks separate "server" variables from "browser" variables with a naming prefix:

  • Next.js: variables starting with NEXT_PUBLIC_
  • Vite: variables starting with VITE_
  • Create React App (deprecated, but still in many older projects): variables starting with REACT_APP_

Variables with the prefix are replaced with their literal value at build time and written into the JavaScript files that get served. Variables without it aren't exposed to client code. The prefix is the framework asking you to confirm that the value is safe to publish.

.env NEXT_PUBLIC_LLM_KEY=… .env LLM_API_KEY=… bundler build time public JS value is in the file server code reads at runtime stays on server never sent to browser prefix = published; no prefix = server only
The bundler copies prefixed variables into the JavaScript it serves. Variables without the prefix are only available to server code.

Two details trip people up:

  • It happens at build time. The value is copied into the output when you run the build. Changing the variable later doesn't change the files already built or already cached by browsers and CDNs.
  • The prefix is a label, not a lock. Nothing checks that the value is actually harmless. If you name a secret NEXT_PUBLIC_LLM_API_KEY, the framework does exactly what you asked.

Here is the pattern AI tools produce when they take the shortest path:

// components/Chat.tsx
"use client";
import { useState } from "react";

export function Chat() {
  const [answer, setAnswer] = useState("");

  async function ask(question: string) {
    // An OpenAI-style chat completions API; the host is a placeholder.
    const res = await fetch("https://llm.example.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        // Inlined into the JavaScript bundle at build time. Public.
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_LLM_API_KEY}`,
      },
      body: JSON.stringify({
        model: "some-model",
        messages: [{ role: "user", content: question }],
      }),
    });
    const data = await res.json();
    setAnswer(data.choices[0].message.content);
  }

  return <button onClick={() => ask("Hello")}>{answer || "Ask"}</button>;
}

Anyone who loads this page can copy that key and call the API as you, from their own scripts, at your expense. A leaked LLM key typically means a bill you didn't expect, plus whatever data the account can reach.

Why AI tools do this

It isn't malice or ignorance. It's the objective. You asked for a feature that works, and calling the API straight from the component needs one file. Doing it properly needs a second piece of code on the server, an auth check, input validation and a rate limit, none of which you mentioned.

The same thing happens with payment providers, email APIs, maps, analytics and database clients. The tool reaches for whatever makes the preview work. And when process.env.LLM_API_KEY turns out to be undefined in the browser, the "fix" that gets suggested is to add the public prefix. That makes the error go away, and it also makes the key public. Treat that error as a useful signal: the code is running in the wrong place.

Publishable keys vs secret keys

Not every key in client code is a bug. Many services deliberately issue browser-safe and server-only credentials:

  • Stripe. The publishable key (starts with pk_) is designed for the browser. The secret key (sk_) must stay on the server. Restricted keys (rk_) are for servers too. See Stripe's API keys documentation.
  • Supabase. The anon key, and the newer publishable key that replaces it, are meant to be in client code, but they're only safe when row-level security is enabled and your policies are correct. Without them, the key can grant broad access. The service_role key (and the newer secret key) bypasses row-level security and must never reach the client. The next post in this series covers this in detail.
  • LLM providers (OpenAI-style keys, Anthropic and others). These are secret keys. There is no browser-safe variant. They never belong in client code.
  • Maps and similar services. Browser keys exist, but they should be restricted in the provider's console, for example by allowed website origin and by API.

The test for a publishable key: if a stranger copied it, what could they do? If the answer is "only what the service already allows any visitor to do", fine. If the answer is "spend my money or read my data", it's a secret.

How to check your own app

Don't trust the source code alone. Check what you actually ship.

1. Search the built output. Build the app, then search the generated files. For Next.js that's .next/static; for Vite it's dist/assets.

npm run build

# Next.js
grep -rEn "sk_live_|sk_test_|service_role|BEGIN (RSA )?PRIVATE KEY" .next/static

# Vite
grep -rEn "sk_live_|sk_test_|service_role|BEGIN (RSA )?PRIVATE KEY" dist/assets

# Any build: look for the names you gave your variables
grep -rEln "LLM_API|SECRET|PRIVATE" .next/static

2. List your public variables. Search the source for every use of the public prefixes and read each name:

grep -rEn "NEXT_PUBLIC_|VITE_|REACT_APP_" --exclude-dir=node_modules \
  --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='.env*' .

Anything with SECRET, PRIVATE, TOKEN or a provider's secret key in its name deserves a second look.

3. Use the browser. Open your deployed site, then DevTools. In the Network tab, click a request to a third-party API and check its headers: is there an Authorization header or an apikey query parameter that you set? In the Sources tab, search all files for the start of your key.

4. Check history too. Env files that were committed once stay in git. See secrets in git history.

Searching for a pattern is a good first pass, but it won't catch everything: a key can be split, encoded or fetched at runtime. A clean grep means "nothing obvious", not "safe".

The fix: keep the key on the server

Move the third-party call into code that only runs on your server, and let the browser call your own endpoint instead. The server route holds the key, checks who is asking, validates the input, and limits how often it can be called. Here is the same feature as a Next.js route handler:

// app/api/chat/route.ts (runs on the server only)
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth";       // your session check
import { checkRateLimit } from "@/lib/rate-limit"; // your limiter

const MAX_QUESTION_CHARS = 500;

export async function POST(req: Request) {
  // 1. Require a signed-in user
  const user = await getSessionUser(req);
  if (!user) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  // 2. Limit how often each user can call it
  if (!(await checkRateLimit(`chat:${user.id}`, { max: 20, windowSeconds: 3600 }))) {
    return NextResponse.json({ error: "rate_limited" }, { status: 429 });
  }

  // 3. Validate the input; never forward the raw request body
  const body = await req.json().catch(() => null);
  const question = typeof body?.question === "string" ? body.question.trim() : "";
  if (!question || question.length > MAX_QUESTION_CHARS) {
    return NextResponse.json({ error: "invalid_input" }, { status: 400 });
  }

  // 4. Call the provider with a key that never leaves the server
  const upstream = await fetch("https://llm.example.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.LLM_API_KEY}`, // no NEXT_PUBLIC_ prefix
    },
    body: JSON.stringify({
      model: process.env.LLM_MODEL,
      max_tokens: 300, // output cap; the parameter name varies by provider
      messages: [{ role: "user", content: question }],
    }),
  });
  if (!upstream.ok) {
    return NextResponse.json({ error: "upstream_error" }, { status: 502 });
  }

  const data = await upstream.json();
  return NextResponse.json({ answer: data.choices?.[0]?.message?.content ?? "" });
}

The component now only talks to your own origin:

const res = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ question }),
});

Each of the four steps matters, because the endpoint is still a public door to a paid service:

  • Auth. Without it, anyone can use your route as a free proxy to your account.
  • Rate limit. Without it, one signed-in user or a script can run up the bill. A later post in this series, on denial-of-wallet attacks, goes deeper on this.
  • Validation. Cap input size, restrict allowed values, and set output limits such as max_tokens. Don't let the client choose the model, the URL or arbitrary parameters.
  • Server-only key. Give it a plain name with no public prefix. Frameworks also have server-only markers (for example, Next.js's server-only package) that make the build fail if a client file imports a server module.

Also set a spending cap or budget alert in the provider's dashboard, and give each key the narrowest permissions it supports. If something does go wrong, the damage has a ceiling. If you're building agents on top of this, the same logic applies: see least privilege for AI agents.

If a key already leaked

Assume it's compromised. The order matters:

  1. Rotate first. Create a new key in the provider's console and revoke the old one. Removing the code first leaves the old key working.
  2. Then remove it from the code and redeploy with the secret held server-side.
  3. Check usage. Look at the provider's usage and audit logs for requests you don't recognise, covering the whole period the key was exposed.
  4. Don't count on deleting a deployment. Old bundles may be cached by browsers, CDNs and third parties, and anyone who fetched them already has the key. Deleting a deployment, or a repository, doesn't un-leak anything. This is the same reason deleting a secret from git history isn't enough: the only fix is that the old value stops working.
  5. Find out how it got there. If an AI tool added the prefix, add a rule to your project instructions so it doesn't happen again.

Getting the AI tool to do it right

You can steer the tool before it writes the code. Useful lines for your project instructions or prompt:

  • "Never use NEXT_PUBLIC_, VITE_ or REACT_APP_ for any secret. Calls to third-party APIs that need a secret key go through a server route."
  • "Every server route that calls a paid API must require authentication, validate input and be rate limited."
  • "If a secret is undefined in client code, tell me instead of adding a public prefix."

Then review the result. Our post on security flaws in vibe-coded apps lists the other patterns worth checking in the same pass.

How OpenRouting helps

OpenRouting's secrets and data exposure agent reviews hardcoded secrets and data leaking through logs and errors, and a full-repo scan flags hardcoded secrets in the files it scans (not yet your git history). Every finding gets a verdict (real or likely false positive), a plain-English explanation and a suggested fix. Pull requests are covered too: PR review reports findings in the changed files and posts one summary comment, so a new key in a client component can be caught before it merges.

The boundary: OpenRouting reviews your code and repository. It doesn't inspect your deployed bundle or your provider's dashboard, and it doesn't enforce rate limits. Whether a variable with a public prefix is actually harmless is a judgement call that stays with you, so keep running the build-output check above.

Questions

Are NEXT_PUBLIC_ environment variables visible in the browser?

Yes. Variables starting with NEXT_PUBLIC_ (like VITE_ in Vite and REACT_APP_ in Create React App) are replaced with their literal value at build time and written into the JavaScript files that get served. The prefix is a label, not a lock: nothing checks that the value is harmless.

Is an API key safe in frontend code if the .env file is in .gitignore?

No. Anything that runs in the browser can be read by whoever opens the page, and minifying or obfuscation doesn't hide it. The question to ask is whether the value ever gets sent to the browser, not whether it is in git.

How do I call an LLM API from a React app without exposing the key?

Move the call into a server route that holds the key, and have the browser call your own endpoint. That route should require a signed-in user, rate limit each user, validate input and keep the key under a name with no public prefix.

What should I do if my API key leaked in a JavaScript bundle?

Treat it as compromised and rotate it first, because removing the code first leaves the old key working. Then remove it from the code, redeploy with the secret held server-side, and check the provider's usage logs for the whole period it was exposed.

Keep reading