Vigia › Help › OpenAI key in frontend

OpenAI API key exposed in your frontend? How to check and fix it

Published 22 October 2026 · About 7 minutes · For apps built with Lovable, Bolt, Cursor, v0 or by hand

Short answer: if your app calls api.openai.com directly from the browser, your OpenAI secret key is in the JavaScript every visitor downloads. Anyone can open DevTools, copy it, and run requests billed to your account.

To fix it: revoke the key first on the OpenAI API keys page, then move every OpenAI call into server-side code (an API route, serverless function or Supabase Edge Function) that reads a new key from a secret. The browser calls your endpoint, never OpenAI.

Why an exposed key matters

OpenAI is explicit about this: “Never deploy your key in client-side environments like browsers or mobile apps.” Exposing it there “allows malicious users to take that key and make requests on your behalf – which may lead to unexpected charges or compromise of certain account data.” (OpenAI Help: Best practices for API key safety)

In practice, a leaked key can mean:

Minified or “hidden” code does not help. Anything the browser can use, a visitor can read.

How keys end up in the browser

AI code tools often produce a working chat feature by calling OpenAI straight from a React component. It works in the preview, so it ships. The usual routes:

How to check your app yourself

Only do this on apps you own. All of these steps look at what your site already serves to every visitor.

1. Watch the Network tab (1 minute)

  1. Open your live app in Chrome, Edge or Firefox and open DevTools (F12 or Cmd+Option+I).
  2. Go to the Network tab and type openai into the filter box.
  3. Use the AI feature in your app (send a chat message, generate something).
  4. If you see a request going to api.openai.com, click it and look at Request Headers. An Authorization: Bearer sk-… header means your secret key is in the browser.

If the request goes to your own domain or to …supabase.co/functions/v1/… instead, that is the safe pattern, as long as the key is not also in the bundle (step 2).

2. Search the JavaScript the browser downloads

In Chrome or Edge DevTools, press Ctrl+Shift+F (Cmd+Option+F on Mac) to search all loaded sources. Search for:

Reload the page with DevTools open first, and click through the app, so lazily loaded chunks are included.

3. Search your production build (most thorough)

If you can run the project locally (export from Lovable/Bolt to GitHub, or your Cursor repo), build it and search the output folder. This catches chunks that only load on certain pages.

# Vite (Lovable, Bolt, most React apps): output is dist/
npm run build
grep -rnoE 'sk-[A-Za-z0-9_-]{20,}' dist/
grep -rn 'api.openai.com\|dangerouslyAllowBrowser' dist/

# Next.js: browser code is in .next/static/
npm run build
grep -rnoE 'sk-[A-Za-z0-9_-]{20,}' .next/static/

Then look for the source of the problem in your code:

grep -rn 'VITE_OPENAI\|NEXT_PUBLIC_OPENAI\|dangerouslyAllowBrowser' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='.env*' . \
  | grep -v node_modules

Any match in dist/ or .next/static/ means the key ships to visitors.

4. Check that .env is not downloadable

curl -s -o /dev/null -w '%{http_code}\n' https://your-app.example/.env
curl -s https://your-app.example/.env | head -5

A 404 is what you want. Many single-page-app hosts return 200 with your normal HTML page for any path; that is fine. A 200 showing lines like OPENAI_API_KEY=… is not.

How to fix it

Order matters. Removing the key from your code does not un-leak it. Old builds, browser caches, CDN caches and your Git history may still contain it. Revoke first, then fix the code.

Step 1: Revoke the exposed key and create a new one

  1. Go to platform.openai.com/api-keys, find the exposed key and delete (revoke) it. Requests using it stop working.
  2. Create a new key, ideally scoped to one project, with an expiration date. OpenAI recommends expiring keys and a rotation process (OpenAI: Production best practices).
  3. Do not put the new key in your frontend. You will store it as a server-side secret in step 2.
  4. Review the Usage page for activity you don't recognise, and set a monthly spend limit with hard enforcement. OpenAI notes that spend alerts alone don't stop API traffic (OpenAI Help). If you see misuse, contact OpenAI support from the Help Center.

Step 2: Move the OpenAI call to the server

The browser should call your endpoint. Your endpoint reads the key from a secret and calls OpenAI.

Lovable / Supabase Edge Function. Lovable's Secrets are “injected into your Edge Functions automatically, and never reach the browser”, and Lovable lists OPENAI_API_KEY as a backend secret, while VITE_ values are browser-exposed (Lovable docs: Secrets). With the Supabase CLI you set it with supabase secrets set OPENAI_API_KEY=sk-… and read it with Deno.env.get (Supabase: Edge Function secrets). A minimal function:

// supabase/functions/ai-chat/index.ts
const cors = {
  "Access-Control-Allow-Origin": "*", // restrict to your domain in production
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: cors });

  const { message } = await req.json();
  if (typeof message !== "string" || message.length > 4000) {
    return new Response("Bad request", { status: 400, headers: cors });
  }

  const r = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "YOUR_MODEL", input: message }),
  });

  return new Response(await r.text(), {
    status: r.status,
    headers: { ...cors, "Content-Type": "application/json" },
  });
});

In the frontend, call it with supabase.functions.invoke("ai-chat", { body: { message } }) and delete any VITE_OPENAI_* variable.

Next.js (Cursor, v0, hand-written). Use a Route Handler and a variable without the NEXT_PUBLIC_ prefix:

// app/api/chat/route.ts  — runs on the server only
export async function POST(req: Request) {
  const { message } = await req.json();

  const r = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // set in your host's env settings
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "YOUR_MODEL", input: message }),
  });

  return new Response(await r.text(), {
    status: r.status,
    headers: { "Content-Type": "application/json" },
  });
}

Your client code then does fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) }). Replace YOUR_MODEL with the model you use.

Step 3: Don't turn your endpoint into a free proxy

If anyone on the internet can call your new endpoint with any prompt, you've moved the problem rather than solved it. At minimum:

Step 4: Redeploy and verify

Deploy, hard-reload your live site and repeat the checks above. The Network tab should show calls to your own endpoint only, and searching for sk- and api.openai.com in the loaded sources should find nothing. If the old key was ever committed to Git, it is still in the history; that is why revoking it in step 1 is the part that actually protects you.

Exception: realtime voice. For OpenAI's Realtime API in the browser, OpenAI's documented pattern is that your server uses the standard key to mint a short-lived ephemeral client secret, and only that ephemeral token goes to the browser (OpenAI: Realtime over WebRTC). The standard key still never leaves the server.

Prompts for Lovable, Bolt and Cursor

Paste one of these into your AI tool after you've revoked the old key. Review the diff before accepting it.

Lovable:

My OpenAI API key is exposed in frontend code. Remove every OpenAI key and any VITE_OPENAI variable from the frontend. Create a Supabase Edge Function that reads OPENAI_API_KEY from Secrets (ask me for the new key through the secure input), checks that the caller is a signed-in user, limits input length, calls OpenAI, and returns the result. Update the frontend to call that function with supabase.functions.invoke instead of calling api.openai.com.

Bolt:

Move all OpenAI API calls out of the browser. Delete any hard-coded sk- key and any VITE_ variable that holds an OpenAI key. Add a server-side function (API route or serverless function) that reads OPENAI_API_KEY from a server-only environment variable, requires a logged-in user, and proxies the request. The client must never call api.openai.com directly.

Cursor (Next.js):

Find every place that calls OpenAI from client components, uses dangerouslyAllowBrowser, or reads NEXT_PUBLIC_OPENAI_*. Replace them with a Route Handler at app/api/chat/route.ts that uses process.env.OPENAI_API_KEY (no NEXT_PUBLIC_ prefix), validates input, requires an authenticated session, and returns the response. Update the client to fetch /api/chat. Show me the diff.

FAQ

Is it safe if my OpenAI key is in an environment variable?

Only if that variable is read on the server. Variables starting with VITE_ (Vite, Lovable, Bolt) or NEXT_PUBLIC_ (Next.js) are copied into the browser bundle at build time, so a key stored in them is public even though it lives in a .env file.

Can I hide the key with obfuscation, base64 or a minifier?

No. If the browser can decode the key to send it to OpenAI, anyone can do the same with DevTools. The only fix is to keep the key on a server and have the browser call your own endpoint.

Will OpenAI disable my key automatically if it leaks?

OpenAI says that when it detects an API key on the public internet, or leaked inside an app in an app store, the key is disabled immediately. Do not rely on that: detection is not guaranteed to happen before someone uses the key, and a disabled key also breaks your app. Revoke it yourself and rotate.

I removed the key from my code. Am I safe now?

Not until you revoke it. Earlier builds, CDN and browser caches, and your Git history can still contain the old key. Delete it on the OpenAI API keys page, create a new one, and store the new key only as a server-side secret.

Is the Supabase anon key in my frontend the same problem?

No. Supabase anon and publishable keys are designed to be used in the browser, as long as Row Level Security is enabled on your tables. The Supabase service_role or secret keys are different: they bypass Row Level Security and must never be in frontend code, just like an OpenAI key.

Does IP allowlisting help with a key in the browser?

Not for browser use. OpenAI's IP allowlisting only accepts requests from IP addresses you configure, such as your backend servers. Your users' browsers have their own IP addresses, so the fix is still to call OpenAI from your server, where allowlisting can add an extra layer.

How Vigia checks this

Vigia is a read-only security check for AI-built apps. For this issue it does part of what you did above, from the outside: it downloads your public page and the same-origin JavaScript files linked from it with <script src>, requests a few files that should never be public (such as /.env, /.env.local, /.env.production, /.git/HEAD and /.git/config), and searches them for secret key patterns, including OpenAI sk- keys. Keys are shown masked in the report (first and last 4 characters). For an exposed OpenAI key, the report includes a fix prompt for Cursor, Lovable and Bolt.

Honest limits: Vigia does not watch network requests, run your app, or log in. It reads the HTML of your page and the script files linked directly from it (up to a small number), so code that only loads later (lazily loaded chunks, pages behind login) can be missed, which is why the build search in step 3 is worth doing once yourself. It also cannot tell whether a key it finds is still active; revoke it either way. A clean report is not a security guarantee. Vigia only checks apps you own or have permission to test.

Vigia is in early access and not launched yet. The free check is one public check of your app. Weekly re-checks with email alerts for the Pro plan are coming soon.

Join the waitlist

Sources

Last reviewed 22 October 2026 against the official docs listed above.