Vigia › Help › OpenAI key in frontend
OpenAI API key exposed in your frontend? How to check and fix it
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:
- Charges you didn't make. Requests made with your key count against your account's quota and spend.
- Your app stops working. If someone burns through your quota, or OpenAI disables the key after detecting it in public, your real users get errors. OpenAI says that when it detects a key on the public internet, the key is disabled immediately (OpenAI Help: Keeping your account secure). That protects you, but it also breaks your feature without warning.
- Access to account data. Depending on the key's permissions, it may reach files, assistants or other resources stored in that project.
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:
- A
VITE_variable. Vite (used by Lovable, Bolt and many React apps) puts everyVITE_*variable into the client bundle at build time. Vite's own docs say these “should not contain sensitive information such as API keys” (Vite: Env variables and modes). SoVITE_OPENAI_API_KEYis public by definition. - A
NEXT_PUBLIC_variable. In Next.js,NEXT_PUBLIC_*values are inlined “into any JavaScript sent to the browser” (Next.js: Environment variables). Without the prefix, a variable stays on the server. - The OpenAI SDK with
dangerouslyAllowBrowser: true. The officialopenaiJavaScript library refuses to run in a browser by default “to avoid exposing your secret API credentials”. Setting that flag switches the safety off (openai-node README). If you see it in your code, your key is almost certainly in the bundle. - A hard-coded string like
const apiKey = "sk-proj-…"pasted into a component during testing. - A public
.envfile. Some hosting setups serve/.envas a plain file, which exposes every secret in it, not just OpenAI's.
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)
- Open your live app in Chrome, Edge or Firefox and open DevTools (
F12orCmd+Option+I). - Go to the Network tab and type
openaiinto the filter box. - Use the AI feature in your app (send a chat message, generate something).
- If you see a request going to
api.openai.com, click it and look at Request Headers. AnAuthorization: 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:
sk-(OpenAI secret keys start with this; matches will include unrelated text, so look for a long random string after it)api.openai.comdangerouslyAllowBrowserOPENAI
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
- Go to platform.openai.com/api-keys, find the exposed key and delete (revoke) it. Requests using it stop working.
- 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).
- Do not put the new key in your frontend. You will store it as a server-side secret in step 2.
- 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:
- Require a signed-in user (check the session or the Supabase user token in the function).
- Limit input size and requests per user.
- Build the prompt on the server; don't let the client choose the model or send raw system prompts.
- Keep the spend limit from step 1 as a backstop.
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.
Sources
- OpenAI Help Center: Best practices for API key safety
- OpenAI Help Center: Keeping your OpenAI account secure
- OpenAI API docs: Production best practices
- OpenAI API docs: Realtime API over WebRTC (ephemeral tokens)
- openai-node README: browser support and dangerouslyAllowBrowser
- Vite: Env variables and modes
- Next.js: Environment variables
- Supabase: Edge Function environment variables and secrets
- Lovable docs: Secrets