# OpenAI API key exposed in the browser: NEXT_PUBLIC_, VITE_ and dangerouslyAllowBrowser explained

Here is a chat feature built the fast way. It is a made-up example, but every line of it is something I have seen in real frontend code:

```bash
# .env.local
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-****************
```

```tsx
// app/chat/Chat.tsx
"use client";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
  dangerouslyAllowBrowser: true,
});
```

The first version used `OPENAI_API_KEY` and the client said "undefined". Adding `NEXT_PUBLIC_` fixed that. Then the SDK refused to run in the browser, and `dangerouslyAllowBrowser: true` fixed that too. The feature works. And your OpenAI API key is now exposed in plain text in a JavaScript file that anyone can open from DevTools, copy, and spend on until you notice the bill.

> **TL;DR:** variables prefixed with `NEXT_PUBLIC_`, `VITE_`, `REACT_APP_` (and `EXPO_PUBLIC_`, `NUXT_PUBLIC_`, `GATSBY_`, SvelteKit and Astro `PUBLIC_`) are inlined into client code at build time. The OpenAI and Anthropic JS SDKs block browser use by default for exactly this reason, and `dangerouslyAllowBrowser: true` turns that guard off. An AI key also leaks through bundler `define`, a server-only variable read in a `"use client"` component, or a key pasted into frontend code. Keep the key in a server-only variable and call the provider from a server route. [BrowserKey](https://marketplace.visualstudio.com/items?itemName=jaytankdev.browserkey) checks all five paths in VS Code.

### Why an OpenAI API key ends up exposed in the browser

Frontend frameworks split environment variables into two groups, and the split is just a name prefix.

**Next.js.** The [environment variables guide](https://nextjs.org/docs/app/guides/environment-variables) says non-`NEXT_PUBLIC_` variables are only available in the Node.js environment. With the prefix, Next.js replaces `process.env.NEXT_PUBLIC_...` with a hard-coded value at build time, and "it will be inlined into any JavaScript sent to the browser."

**Vite.** The [env and mode guide](https://vite.dev/guide/env-and-mode) says variables prefixed with `VITE_` are exposed in client-side source code after bundling, and has a "Protecting secrets" note: "VITE\_\* variables should not contain sensitive information such as API keys."

**Create React App.** The [CRA docs](https://create-react-app.dev/docs/adding-custom-environment-variables/) put it in a warning box: "Do not store any secrets (such as private API keys) in your React app!"

So the prefix is not a configuration detail. It is a decision to publish the value. For an analytics ID, that is fine. For an AI provider key, it means anyone can call the API on your account.

### dangerouslyAllowBrowser: the SDK already warned you

The official SDKs see this coming. The [openai-node README](https://github.com/openai/openai-node) lists web browsers as "disabled by default to avoid exposing your secret API credentials", and explains that enabling `dangerouslyAllowBrowser` "exposes your secret API credentials in the client-side code". The [Anthropic TypeScript SDK README](https://github.com/anthropics/anthropic-sdk-typescript) uses the same wording. When the SDK throws in the browser, the error is the feature. Setting the flag to `true` silences the one check that was about to save you.

There is one legitimate use: an app where each user pastes their own key, which stays in their browser. That is a different threat model, and it is the only case where the flag is reasonable.

### The five ways AI keys reach the bundle

1. **A public prefix on the key.** `NEXT_PUBLIC_OPENAI_API_KEY`, `VITE_ANTHROPIC_KEY`, `REACT_APP_GEMINI_API_KEY`, in `.env`, a CI workflow, a Dockerfile or compose file.
2. **Reading that public variable in client code.** The `.env` line is the cause, the `import.meta.env.VITE_...` read is where it gets shipped.
3. `dangerouslyAllowBrowser: true` on an `OpenAI`, `AzureOpenAI`, `Anthropic` or `Groq` client.
4. **A server-only variable in a client file.** `process.env.ANTHROPIC_API_KEY` inside a `"use client"` component is `undefined` in the browser. The call fails, and the usual "fix" is to add a public prefix, which brings you back to path 1.
5. **Build-config inlining.** Vite [`define`](https://vite.dev/config/shared-options), webpack `DefinePlugin` or `EnvironmentPlugin`, `env` in `next.config`, or an `envPrefix` so wide it publishes everything. These replace the expression with the literal value at build time, prefix or not.

And the sixth, always: a key pasted straight into a component "just to test".

### How to fix it by hand

Move the provider call to the server, and have the page call your own route:

```ts
// app/api/chat/route.ts (runs on the server)
import OpenAI from "openai";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function POST(req: Request) {
  const { message } = await req.json();
  const res = await openai.responses.create({ model: "gpt-4.1-mini", input: message });
  return Response.json({ text: res.output_text });
}
```

```tsx
// app/chat/Chat.tsx (client)
const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) });
```

Then:

- Rename `NEXT_PUBLIC_OPENAI_API_KEY` to `OPENAI_API_KEY` everywhere (`.env`, CI secrets, hosting settings).
- Remove `dangerouslyAllowBrowser`.
- **Rotate the key.** If it was ever in a deployed bundle, assume someone has it. Removing it from the code does not remove it from old builds, CDN caches or archived copies.
- Add rate limits and auth to your new route, since it is now the thing that spends money.

In a Vite single-page app with no server, the same idea applies: a small serverless or edge function holds the key, and the browser calls that.

### BrowserKey: catch AI API keys before they reach the browser

I built **BrowserKey**, a free VS Code extension that finds AI provider API keys on their way into browser or app bundles. It knows the public prefixes of Next.js, Vite, Create React App, Expo, Nuxt, Gatsby, SvelteKit and Astro, the SDKs with a `dangerouslyAllowBrowser` option, and the key formats of OpenAI, Anthropic, Groq, xAI, OpenRouter, Perplexity, Hugging Face, Replicate and Gemini. Secrets are always masked in its output, and it runs fully offline.

```plaintext
code --install-extension jaytankdev.browserkey
```

![](https://cdn.hashnode.com/uploads/covers/6a548baa47c6120a79511a69/cf70f6cc-bdca-4a14-96fa-4b9d4b56233f.png align="center")

On a small made-up project (the Next.js chat above plus a Vite folder, fake keys only), BrowserKey reports this. These are the real panel labels from its analyzer:

- **BK001** in `.env.local`: "NEXT_PUBLIC_OPENAI_API_KEY in .env.local is bundled into the browser", with the Quick Fix "Rename to OPENAI_API_KEY (server-only)".
- **BK002** in `Chat.tsx`: "NEXT_PUBLIC_OPENAI_API_KEY read in client code is shipped to the browser".
- **BK003** in `Chat.tsx`: "new OpenAI({ dangerouslyAllowBrowser: true }) exposes the API key".
- **BK004** in `Chat.tsx`: "server-only ANTHROPIC_API_KEY read in a "use client" component" (a warning, since the key is not shipped yet).
- **BK005** in `web/vite.config.ts`: "OPENAI_API_KEY inlined into the bundle by vite define".
- **BK006** in `web/src/ai.ts`: "hardcoded OpenAI key sk-proj-\*\*\*\* in browser code".

Each diagnostic has a longer explanation. For BK003 it reads: "The SDK disables browser use by default "to avoid exposing your secret API credentials": any visitor can read the key from the page and spend on it." Every message ends with the same advice: keep the key in a server-only variable and call the provider from a server route.

The **BrowserKey icon in the activity bar** opens the **Browser key checks** panel with every finding, a badge with the count, and a status bar item that shows `BrowserKey: N`. There is a command to open the framework or SDK doc behind each finding.

### The rules

| ID | What it catches | Default |
| --- | --- | --- |
| BK001 | Public-prefixed variable holds an AI provider key | Error |
| BK002 | Public AI key read in client code | Error |
| BK003 | SDK client created with dangerouslyAllowBrowser: true | Error (Information when the key comes from the user) |
| BK004 | Server-only AI key read in a client-side file | Warning |
| BK005 | AI key inlined into the bundle by the build config | Error |
| BK006 | Hardcoded AI provider key in frontend code | Error |

Severities are set with `browserKey.rules`. If your build exposes another prefix, add it with `browserKey.extraPublicPrefixes` (a Vite `envPrefix` or SvelteKit `publicPrefix` written as a string literal is picked up on its own). A key to your own proxy can be skipped with `browserKey.ignoreNames`, for example `^NEXT_PUBLIC_.*_PROXY_KEY$`.

### How it avoids noise

BrowserKey reads `package.json` to learn which framework each folder uses, so `PUBLIC_` only counts in SvelteKit and Astro projects. A variable counts as an AI key when its name says so (`OPENAI`, `ANTHROPIC`, `GEMINI`, `GROQ` and so on, plus a key word like `KEY` or `TOKEN`), or when its value matches a known key format. Names that end in `_URL`, `_MODEL` or `_ID` are configuration, not secrets. Placeholder values like `sk-...` or `your-key-here` are never reported by value. Google `AIza` keys are also used by Firebase and Maps in the browser on purpose, so they only count when the file or name says Gemini. And when `dangerouslyAllowBrowser` is fed from a user input field, it drops to information level.

### Privacy

No network, no telemetry, no child processes. BrowserKey never shows a full key: only the documented prefix, such as `sk-proj-`, and the rest is masked. In an untrusted workspace, settings that hide findings come only from your own user settings.

### Limitations

- It is a static check of your source and config files. It can not see variables set only in your hosting dashboard, or what a third-party library does at runtime.
- A key is recognized by its name or its format. A key in a variable called `MY_TOKEN` with an unknown format is not reported.
- Built output (`dist`, `build`, `.next`, `.nuxt`, `.output`, `.svelte-kit`) is excluded by default, so it checks what you write, not what you ship.
- Files over 1 MB are skipped, and `browserKey.maxFiles` (default 3000) caps a workspace scan.

### FAQ

**Is it safe to use NEXT_PUBLIC\_ for an OpenAI API key?** No. Next.js inlines `NEXT_PUBLIC_` values into the JavaScript sent to the browser. Use a server-only variable and a Route Handler or server action.

**How do I hide an API key in a Vite app?** You can not hide it in client code. Vite's docs say `VITE_*` variables should not contain API keys. Put the call in a serverless or edge function.

**What does dangerouslyAllowBrowser do?** It turns off the OpenAI or Anthropic SDK's check that refuses to run in a browser. The SDK docs warn it exposes your secret API credentials.

**My key was in a deployed bundle. Is deleting it enough?** No. Revoke and rotate it in the provider console. Old builds and caches may still contain it.

### Try it

- **Install:** search "BrowserKey" in the VS Code Extensions view, or run `code --install-extension jaytankdev.browserkey`
- **Marketplace:** [marketplace.visualstudio.com/items?itemName=jaytankdev.browserkey](https://marketplace.visualstudio.com/items?itemName=jaytankdev.browserkey)
- **Source (MIT):** [github.com/jay-tank/browserkey](https://github.com/jay-tank/browserkey)

A leaked AI key is one of the few bugs that sends you an invoice. Open your frontend once with BrowserKey installed, and you will know if yours is out there.

Issues and suggestions are very welcome on GitHub.
