IntentiAI

LLM API key shipped to the browser

A provider key shipped to the browser.

Rule client-side-key-exposureCost audit

Why it costs money

Build tools copy environment variables with a public prefix (NEXT_PUBLIC_, VITE_, REACT_APP_ and others) into the JavaScript bundle sent to every visitor. Anyone can open the developer tools, find the key, and use it from their own code.

Once someone has the key, your provider limits are the only limit. Requests made with it are billed to your account and do not pass through your app, so your own rate limits and logs never see them. The OpenAI SDK requires dangerouslyAllowBrowser: true to run in a browser for exactly this reason.

Example

Flagged

"use client";
import OpenAI from "openai";

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

export async function suggestTitle(draft: string) {
  const res = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    max_tokens: 30,
    messages: [{ role: "user", content: "Suggest a title for: " + draft }],
  });
  return res.choices[0].message.content;
}

Fixed

// app/api/suggest-title/route.ts (runs on the server)
import OpenAI from "openai";

const openai = new OpenAI(); // reads OPENAI_API_KEY on the server only

export async function POST(req: Request) {
  const { draft } = await req.json();
  const res = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    max_tokens: 30,
    messages: [{ role: "user", content: "Suggest a title for: " + String(draft).slice(0, 4000) }],
  });
  return Response.json({ title: res.choices[0].message.content });
}

// In the client component
export async function suggestTitle(draft: string) {
  const res = await fetch("/api/suggest-title", { method: "POST", body: JSON.stringify({ draft }) });
  return (await res.json()).title as string;
}

How to fix it

Move the model call to a server route that holds the key, and have the browser call that route. Drop the public prefix from the variable name so the build cannot include it.

Rotate any key that has already shipped in a public bundle, since old builds and caches may still contain it. Then add auth and rate limits to the new server route, so the key is not simply exposed one step further back.

What the audit detects

  • A line that reads an environment variable with a public build prefix (NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_, PUBLIC_, GATSBY_) whose name includes an AI provider and KEY, TOKEN or SECRET, for example NEXT_PUBLIC_OPENAI_API_KEY.
  • An SDK client configured for direct browser use: dangerouslyAllowBrowser: true, or the anthropic-dangerous-direct-browser-access header.
  • Comments and import lines are skipped, and a line that only names the variable without reading it does not count.

Research

Create React App docs: Adding custom environment variables. Build-time public environment variables are embedded in the code shipped to the browser and are visible to anyone who inspects the app's files.

LLM API key shipped to the browser: LLM cost rule | IntentiAI