Skip to content
Back to field notes
AI risksJune 6, 20266 min read

Exposed LLM API keys in browser-based AI chat apps

An AI chat app that calls the model provider straight from the browser ships its API key in the client bundle, where any visitor can read it and spend on your account. This note shows how to confirm it before you share the URL.

AI appsAPI keysLLM

The key that ships with the chat box

The concrete risk: when you have an LLM API key exposed in the browser AI chat app you just built, anyone who opens the page can read that key and spend money on your account. The chat box works because the page calls the model provider directly from JavaScript running in the visitor's browser. To do that, the provider key has to be present in the client code, which means it is inlined into the bundle the browser downloads. That is not a hypothetical leak. It is observable: the key is sitting in a file your server hands to every visitor.

This is what is actually observable from outside: a request leaving the browser straight to a provider host such as api.openai.com or api.anthropic.com, carrying an Authorization header or apikey field with a key that starts with sk-, sk-proj-, or sk-ant-. What is only possible, not proven, is how fast it gets abused. But the path is well understood: automated scanners crawl public bundles and deploy URLs continuously, and a billable key found in client code is the kind of thing that gets picked up and drained. You do not have to assume malice from your own users for this to matter.

One precise framing, because it changes the fix. This is not an authentication problem with your app. Your login can be perfect. It is that the provider key is a bearer credential: whoever holds it is authorized by the provider, full stop. Shipping it to the browser hands that credential to everyone.

Why AI-built chat apps land here

The fastest way to make a chat app respond is to call the model API from the front end. When you ask an AI builder for "a chat app that uses GPT" or "a Claude-powered assistant," the generated scaffold often wires the provider SDK directly into a React or Vue component, reads the key from an env var, and calls the API on submit. It works on the first try, which is exactly why the problem hides.

The env var naming makes it worse, not better. A key stored as VITE_OPENAI_API_KEY or NEXT_PUBLIC_ANTHROPIC_KEY is, by the framework's own rules, meant to be public. The build step inlines any VITE_ or NEXT_PUBLIC_ value into the client bundle on purpose. So the tool did the prefix correctly for a client-side call, and in doing so guaranteed the secret ships to the browser. For the full mechanics of that build-time inlining, see the note on VITE_ environment variables exposed in the client bundle.

  • The generated chat component calls the provider from the browser, so the key must be in client code.
  • A VITE_ or NEXT_PUBLIC_ prefix on a provider key is the signal it was wired for the front end, and that prefix sends it to every visitor.
  • It works in testing because the key is valid. The bill arrives later, after the URL spreads.
  • A skipped review loop means no one asked the one question that catches this: should the model call happen on the server instead.

Confirm it on your own app

You can verify this yourself from a clean browser session, no attack required. Do this against your own deployed URL.

Open DevTools and the Network tab, then send one message in your chat UI. Watch where the request goes. The safe result is a request to your own domain, for example a POST to /api/chat on your origin, with no provider key visible in the request. The suspicious result is a request going straight to a provider host such as api.openai.com, api.anthropic.com, or a similar model endpoint, with an Authorization or apikey header carrying a key in plain text. If the browser is talking to the provider directly, the key that authorized that call is on the client.

Then check the bundle directly. In DevTools open Sources, or use the search across loaded scripts, and search the loaded JavaScript for the provider key prefixes as plain text: sk-, sk-proj-, sk-ant-, or the var names VITE_OPENAI_API_KEY and NEXT_PUBLIC_. A match in a .js file your site serves means the key is downloadable by anyone.

  • Suspicious: a network call from the browser to a provider host carrying a key, or a provider key prefix found in your client bundle.
  • Safer: every model call goes to a route on your own domain, and a search of the bundle finds no provider key string.
  • Note on source maps: if your production build ships .map files, the key can be even easier to read in reconstructed source. The note on source maps and public exposure covers why to disable them for production.

Move the call behind a server route

The fix is structural, not a config flag: the browser should never hold the provider key. Put the model call on a server you control and let the browser talk only to that server.

Create a server-side route in your own backend, for example a serverless function or an API route, that reads the provider key from a server-only env var (one without the VITE_ or NEXT_PUBLIC_ prefix, so it is never inlined into the client). The browser sends the user's message to your route. Your route attaches the key, calls the provider, and streams the answer back. The key stays on the server. After you make this change, rotate the old key in the provider dashboard, because the previous one was public and must be treated as compromised even if you saw no abuse.

What this fix does not cover, and you should know the limits plainly:

  • Moving the call server-side protects the key. It does not stop someone from hammering your route to run up your bill through your own proxy. You still need rate limiting and, for anything beyond a demo, auth on that route.
  • A server route does not address what the model is asked to do. Untrusted user input reaching the model is a separate class of problem; see the note on prompt injection as a launch risk.
  • It does not validate or sanitize what the model returns. Rendering model output as HTML is its own risk and needs separate handling.

Rotating the key and proxying the call closes the exposure you found. It does not make the chat feature safe on its own. Rate limiting, auth on the proxy route, and input handling are distinct jobs that each need their own decision.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check inspects the public bundle and network responses from outside the browser and flags provider key patterns embedded in client code, including OpenAI and Anthropic key shapes such as sk-, sk-proj-, and sk-ant-. If your deployed chat app ships a model provider key to the client, that shows up as a launch blocker with severity and fix direction, surfaced from the same public surface a visitor or a scanner would see. It checks the thing that is actually reachable.

What it does not do is confirm your server route is rate-limited, decide whether your proxy needs auth, or review how user input flows into the model. Those are server-side and business-logic questions that need manual review of your code, not a public-surface scan.

Next step: open your deployed app, send one message with the Network tab open, and confirm the model call goes to your own domain and not a provider host. If it goes straight to the provider, move the call to a server route and rotate the key before you share the URL. Run a focused Launch Check against the public URL first, so an exposed key shows up in your scan and not on someone else's invoice.

A clean Launch Check means no provider key was found in the public bundle or network responses it inspected. It does not prove the chat feature is safe to abuse, that your proxy enforces limits, or that input handling is sound. Treat a flagged key as a launch blocker; treat a clean result as one signal among several.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.