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:
- 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. AnAuthorization: Bearer sk-...header means the key is in the browser. - Open Sources (or view source and open the
/assets/index-[hash].jsbundle) and search forsk-proj-andsk-. - In your code, search for
VITE_OPENAI,NEXT_PUBLIC_OPENAIanddangerouslyAllowBrowser: 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.
- Go to platform.openai.com/api-keys and revoke (delete) the exposed key.
- Check usage for the current billing period on the OpenAI dashboard for anything you don't recognize.
- 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.
- Create a new key. Don't paste it into any
VITE_orNEXT_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-Originto your live domain.
Step 5: Clean up and confirm
- Delete
VITE_OPENAI_API_KEY/NEXT_PUBLIC_OPENAI_API_KEYfrom your host's environment settings (Lovable, Bolt, Netlify, Vercel) and from any.envfile in the repo. - Remove
dangerouslyAllowBrowser: trueand any OpenAI SDK import from frontend files. - Rebuild and redeploy the frontend. Old bundles keep the old value until you rebuild.
- Repeat Step 1 on the live site in an incognito window: no request to
api.openai.comfrom the browser, and nosk-in the JavaScript. - 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:
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.