Is your app exposed?Free scan →
SecurityOct 13, 2026 · 6 min read

Why Cursor Ships Your OpenAI Key to Every Visitor (CWE-522)

AI editors fix the OpenAI SDK browser error with dangerouslyAllowBrowser and a VITE_ or NEXT_PUBLIC_ key, publishing it to every visitor. The fix, plus a bundle check.

SW

SafeWeave

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: true and moving the key into a VITE_ or NEXT_PUBLIC_ variable.
  • That ships your secret API key to every visitor. It sits in the JavaScript bundle and in the Authorization header 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:

  1. Vite inlines every VITE_ variable into the built JavaScript, and Next.js does the same with NEXT_PUBLIC_. The key is a string in a file anyone can download.
  2. 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.

Check your own code for issues like these

A free SafeWeave account runs 7 scanners locally, inside your AI editor. Cloud adds hosted scans and PR checks, with a 14-day trial.

Create a free account

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, run grep -rE "sk-(proj|ant)-|sk-[A-Za-z0-9]{32}" dist/ (or .next/static/ for Next.js). It should return nothing. A plain sk- 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.

Check your app in three steps

  1. Step 1

    Free scan, no signup

    See what your app exposes right now.

    Scan your app
  2. Step 2

    Create a free account

    Connect 1 repository and see its grade and finding counts.

    Sign up free
  3. Step 3

    Cloud

    Hosted scans on every push and PR checks: 5 repos, 15 scans per repo a month, $29/mo with a 14-day trial.

    See pricing