TL;DR
- When the OpenAI SDK throws "It looks like you're running in a browser-like environment", AI editors usually fix it by adding
dangerouslyAllowBrowser: trueand moving the key into aVITE_orNEXT_PUBLIC_variable. - That ships your secret API key to every visitor. It sits in the JavaScript bundle and in the
Authorizationheader of every request in the Network tab. - The fix: delete the flag, rotate the key, and call the model from a server route that checks the session, rate limits, and caps tokens.
You are building an AI feature in a Vite or Next.js app. You install openai, create the client in a React component, and the page crashes with an error that starts "It looks like you're running in a browser-like environment." You paste it into Cursor or Claude Code.
The answer comes back in seconds: add dangerouslyAllowBrowser: true, and since the browser cannot read process.env.OPENAI_API_KEY, rename it to VITE_OPENAI_API_KEY. The chat works. You ship.
Your key is now public. The error you silenced was the SDK telling you exactly that.
The Vulnerable Code
The vulnerable pattern is an LLM client constructed in browser code with a key from a public environment variable. Here is the shape that comes back after the AI "fixes" the error in a Vite + React app:
// src/components/Chat.tsx
// CWE-522: Insufficiently Protected Credentials
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
});
export async function ask(prompt: string) {
const res = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
});
return res.choices[0].message.content;
}
The Next.js version is the same with process.env.NEXT_PUBLIC_OPENAI_API_KEY in a 'use client' component.
Two things make this a leak, not a risk:
- Vite inlines every
VITE_variable into the built JavaScript, and Next.js does the same withNEXT_PUBLIC_. The key is a string in a file anyone can download. - The browser calls the provider directly, so the key is sent as
Authorization: Bearer sk-...on every request. Open DevTools, click the Network tab, send one chat message, and copy it.
No exploit is needed. The attacker is anyone who opens DevTools, and the bill is yours.
Why This Keeps Happening
AI editors optimize for making the error disappear, and the error message itself contains the option that does it. OpenAI's SDK refuses to run in a browser by default, explains why, and names dangerouslyAllowBrowser as the switch. The Anthropic TypeScript SDK has the same option. From the model's point of view, the fix is printed right there in the stack trace.
The second half follows from the first. Once the client lives in the browser, process.env.OPENAI_API_KEY is undefined, so the next error is a missing key. The shortest path is the public prefix, because that is what Vite and Next.js docs say to use when the browser needs a variable.
Plenty of tutorials and demo repos do exactly this, because it is the fastest way to get a chat box on screen. The OpenAI SDK README even lists cases where the flag "might not be dangerous": internal tools with trusted users, keys with very limited scope, short-lived dev keys. A production app with public sign-up fits none of them.
The Fix
Remove dangerouslyAllowBrowser, rotate the exposed key, and move the model call into a server route that authenticates the caller, rate limits per user, and caps output tokens. The browser calls your route, never the provider.
Next.js App Router:
// app/api/chat/route.ts (server only)
import OpenAI from 'openai';
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth'; // your auth
import { rateLimit } from '@/lib/rate-limit'; // e.g. Upstash, Redis
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no NEXT_PUBLIC_
export async function POST(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
if (!(await rateLimit(session.userId))) {
return NextResponse.json({ error: 'slow down' }, { status: 429 });
}
const { prompt } = await req.json();
if (typeof prompt !== 'string' || prompt.length > 4000) {
return NextResponse.json({ error: 'bad input' }, { status: 400 });
}
const res = await openai.chat.completions.create({
model: 'gpt-4o-mini',
max_tokens: 500,
messages: [{ role: 'user', content: prompt }],
});
return NextResponse.json({ text: res.choices[0].message.content });
}
The client component only talks to your route:
// 'use client'
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const { text } = await res.json();
A Vite app has no server of its own, so the route goes in whatever backend you already have: a Supabase Edge Function, a Cloudflare Worker, a Vercel or Netlify function, or a small Express server. The rules are the same. The key lives in that backend's secrets, never in a VITE_ variable.
Then clean up properly:
- Rotate the key. Deleting the variable does not help. Every bundle you already deployed still contains the old key, and so do your git history and any CDN caches.
-
Check the bundle. After
npm run build, rungrep -rE "sk-(proj|ant)-|sk-[A-Za-z0-9]{32}" dist/(or.next/static/for Next.js). It should return nothing. A plainsk-search also matches words like "risk-", so use the pattern. - Set a spending limit on the key's project in your provider dashboard, so a leak you missed has a ceiling.
FAQ
Q: Is dangerouslyAllowBrowser: true ever safe?
A: Only where every user is trusted, like an internal tool, or with a key that has a tiny scope and a short life. For a public app, no. Anyone who loads the page can read the key.
Q: Can I hide the key with obfuscation or environment variables?
A: No. Anything the browser needs to send a request, the user can read. VITE_ and NEXT_PUBLIC_ variables are inlined into the bundle as plain strings, and the key also appears in every request header.
Q: My key was exposed. Is deleting it from the code enough?
A: No. Rotate it in the provider dashboard first. Old builds, git history and caches keep the original value.
I've been running SafeWeave for this. Its cloud repo scans (Cloud and Cloud Plus plans) flag an AI SDK client created with dangerouslyAllowBrowser: true and an LLM key read from a NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_ or REACT_APP_ variable, and the live app scan checks your deployed site's code for provider keys. It also hooks into Cursor and Claude Code as an MCP server. Even without it, a grep of your build output for key prefixes and one look at the Network tab will tell you if you have this problem. The important thing is to look before someone else does.
Top comments (1)
Sharp framing, and the mechanical part is the interesting one: the SDK prints the exact switch that makes the error go away, so the model takes the shortest path to green. Same shape as the recent prompt-injection findings, where the guardrail rejects an instruction in plaintext and accepts it once the agent decrypts it. The model optimizes the symptom, and here the symptom is the security control talking.
Two things I would add from having eaten this one:
Rotating the key is not enough when the leak came through the build. VITE_ and NEXT_PUBLIC_ values are inlined at build time, so the old key keeps living inside every already-deployed bundle until you rebuild and redeploy, not just until you rotate it in the provider dashboard.
Make it fail at build time rather than at review time. A CI step that greps dist/ (or .next/static) for sk- and sk-ant- catches this in about four lines, and it does not depend on anyone remembering the rule during a deadline.