My OpenAI key can be seen in my Lovable or Bolt app. How do I fix it?

Checked on

if your app reads the OpenAI key from import.meta.env.VITE_OPENAI_API_KEY (or process.env.NEXT_PUBLIC_OPENAI_API_KEY) in frontend code, that key is in the JavaScript every visitor downloads. Anyone can copy it and run up your bill. Revoke it now, create a new key that only a server can read, and have the browser call your own endpoint (for example, a Supabase Edge Function) instead of api.openai.com.

Why it happens

This isn't a bug in Lovable, Bolt, Cursor or Vite. It's how the VITE_ prefix is meant to work. Vite's docs say variables prefixed with VITE_ "will be exposed in client-side source code after Vite bundling" and "should not contain sensitive information such as API keys" (Vite: Env Variables and Modes). Next.js does the same with NEXT_PUBLIC_: those values are inlined into the browser bundle at build time (Next.js: Environment Variables).

When you ask an AI builder to "add ChatGPT to my app", it often writes the OpenAI call in a React component. That code runs in the browser, so the browser needs the key, so the tool tells you to add VITE_OPENAI_API_KEY. Everything works and nothing errors, but the key is now public. OpenAI's own guidance is explicit: "Never deploy your key in client-side environments like browsers or mobile apps" (OpenAI Help: Best Practices for API Key Safety).

Step 1: Check whether your key is exposed (2 minutes)

Do these checks on the live site, not the editor preview:

  1. Open your app, press F12, open the Network tab and use the AI feature once. If you see a request go straight from the browser to api.openai.com, click it and look at the request headers. An Authorization: Bearer sk-... header means the key is in the browser.
  2. Open Sources (or view source and open the /assets/index-[hash].js bundle) and search for sk-proj- and sk-.
  3. In your code, search for VITE_OPENAI, NEXT_PUBLIC_OPENAI and dangerouslyAllowBrowser: true. The official OpenAI JS SDK refuses to run in a browser unless that flag is set, so finding it is a strong sign the key is client-side.

If any of these hit, treat the key as already stolen. Bots scan public bundles and GitHub for key patterns all the time.

Step 2: Revoke the key and contain the damage

Do this before changing any code. Removing the key from your source doesn't remove it from bundles that browsers and CDNs have already cached.

  1. Go to platform.openai.com/api-keys and revoke (delete) the exposed key.
  2. Check usage for the current billing period on the OpenAI dashboard for anything you don't recognize.
  3. Set a monthly budget and usage alerts for the project in your OpenAI organization's limits settings, so a future leak can't run unbounded.
  4. Create a new key. Don't paste it into any VITE_ or NEXT_PUBLIC_ variable.

Step 3: Move the OpenAI call to the server

If your app uses Supabase (most Lovable and many Bolt apps do), an Edge Function is the shortest route.

3a. Store the new key as an Edge Function secret. In the Supabase dashboard, go to Edge Functions → Secrets, or use the CLI:

supabase secrets set OPENAI_API_KEY=sk-proj-...

Functions can read new secrets right away without a redeploy, and secret names can't start with SUPABASE_ (Supabase: Edge Function environment variables).

3b. Create the function at supabase/functions/chat/index.ts:

import { createClient } from 'npm:@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*', // tighten to your domain once it works
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  })

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

  // 1. Only signed-in users may spend your OpenAI credit.
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!, // injected by Supabase (legacy name, still provided); respects RLS
    { global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } } },
  )
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return json({ error: 'Not signed in' }, 401)

  // 2. Accept only what you need from the browser, and cap it.
  const { prompt } = await req.json()
  if (typeof prompt !== 'string' || prompt.length > 4000) {
    return json({ error: 'Invalid prompt' }, 400)
  }

  // 3. The key is read on the server and never sent to the browser.
  const r = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini', // keep whichever model your app already uses
      messages: [{ role: 'user', content: prompt }],
      max_tokens: 500,
    }),
  })
  if (!r.ok) return json({ error: 'AI request failed' }, 502)
  const data = await r.json()
  return json({ text: data.choices?.[0]?.message?.content ?? '' })
})

Deploy it with supabase functions deploy chat. If the browser shows a CORS error, see Supabase Edge Function CORS error. Lovable and Bolt projects connected to Supabase can deploy it from the editor too.

3c. Call your function from the frontend instead of OpenAI:

const { data, error } = await supabase.functions.invoke('chat', {
  body: { prompt },
})

supabase.functions.invoke sends the signed-in user's session token, which is what lets the function's auth.getUser() check pass.

Not on Supabase? Next.js: put the call in a Route Handler (app/api/chat/route.ts) and read process.env.OPENAI_API_KEY, with no NEXT_PUBLIC_ prefix. Netlify or Vercel with plain Vite: use a serverless function and set the variable without the VITE_ prefix. The rule is the same everywhere: the key's variable name must not carry a browser prefix.

Step 4: Close the open proxy

A server route that anyone can call without limits costs you the same as a leaked key. Before you call it done:

  • Keep the signed-in check (above) and test the function while logged out. It should return 401.
  • Cap input size and max_tokens, and consider a simple per-user daily limit (for example, a counter table in Supabase that the function checks and increments).
  • Tighten Access-Control-Allow-Origin to your live domain.

Step 5: Clean up and confirm

  1. Delete VITE_OPENAI_API_KEY / NEXT_PUBLIC_OPENAI_API_KEY from your host's environment settings (Lovable, Bolt, Netlify, Vercel) and from any .env file in the repo.
  2. Remove dangerouslyAllowBrowser: true and any OpenAI SDK import from frontend files.
  3. Rebuild and redeploy the frontend. Old bundles keep the old value until you rebuild.
  4. Repeat Step 1 on the live site in an incognito window: no request to api.openai.com from the browser, and no sk- in the JavaScript.
  5. If the key was ever committed to GitHub, it stays in git history. Rotating it (Step 2) is what protects you. GitHub's guide covers the rest (GitHub Docs: Remediating a leaked secret).

The same applies to Anthropic (sk-ant-), Google AI (AIza...), Stripe secret keys (sk_live_) and the Supabase service_role key: none of them belong in a VITE_ or NEXT_PUBLIC_ variable. If your Supabase service role key leaked, see Supabase service_role key exposed.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with, as is:

Fix prompt
My OpenAI API key is exposed in the browser bundle (it is read from a VITE_ / NEXT_PUBLIC_ env variable in frontend code). Fix it like this, and do not change any other feature:

1. Find every place the frontend calls OpenAI directly (fetch to api.openai.com, the openai SDK, dangerouslyAllowBrowser: true, import.meta.env.VITE_OPENAI_API_KEY or process.env.NEXT_PUBLIC_OPENAI_API_KEY). List them for me first.
2. Create a Supabase Edge Function named "chat" (or a server API route if this project has no Supabase) that:
   - reads the key with Deno.env.get('OPENAI_API_KEY') (server-only, no VITE_/NEXT_PUBLIC_ prefix),
   - answers OPTIONS preflight requests and adds CORS headers to every response, including errors,
   - rejects requests without a signed-in user (401) by calling supabase.auth.getUser() with the request's Authorization header,
   - validates and caps the input size and sets max_tokens,
   - returns only the text the frontend needs, never the key.
3. Replace every direct OpenAI call in the frontend with supabase.functions.invoke('chat', { body: {...} }).
4. Remove the OpenAI SDK and dangerouslyAllowBrowser from frontend code and delete VITE_OPENAI_API_KEY / NEXT_PUBLIC_OPENAI_API_KEY from env files and config.
5. Tell me exactly where to add OPENAI_API_KEY as a server secret (Supabase Edge Function secrets), and remind me to revoke the old key at platform.openai.com/api-keys.
Do not print, log or hard-code any API key anywhere.

How to find out sooner next time

AI builders regenerate code whenever you prompt them, and a later "add a summary feature" can put the key back in the frontend without telling you. Re-run the Step 1 check after big changes. If you'd rather not do it by hand, Heedza's free scan reads your homepage and up to 6 of your own JavaScript files, reports exposed secret keys it finds there (including OpenAI sk-proj- and Anthropic sk-ant- keys), and lists missing security headers (which ones matter). You don't need an account, and it doesn't submit forms or enter a card.

Let Heedza check this for you. Heedza’s security checks looks for secret keys left in your page code and private files anyone can open. It checks again every time it checks your pages. It never logs in or fills in forms, so it can’t see errors that only show after that.

Made with Lovable, Bolt, Replit, Cursor or v0? Free for 1 site: start free or see prices.

More questions

Is VITE_OPENAI_API_KEY safe if my repo is private?

No. A private repo doesn't help, because the value is copied into the public JavaScript file your site serves.

Can I hide the key by obfuscating or base64-encoding it?

No. The browser has to decode it before it can use it, so anyone can do the same.

Is the Supabase anon / publishable key the same problem?

No. That key is designed to be public, as long as Row Level Security is on for every table. OpenAI, Stripe secret and service_role keys are never safe in the browser. See Is the Supabase anon key safe in the browser?.

I moved the call to a server route. Am I done?

Only if the route checks who is calling and limits usage (Step 4). Otherwise it's an open proxy to your OpenAI account.

Where this comes from