OpenAI, Claude or Gemini API key exposed in your frontend? What to do now
By Pritesh Ghodge, security engineer · Last updated 31 October 2026
Not sure if your key is in your JavaScript?
The Abbi Labs quick check reads the same files every visitor downloads and looks for OpenAI, Anthropic, Google, Stripe, AWS, GitHub and Supabase secret keys. It shows only a masked prefix, never the full key. No sign-up.
Weekly AI-app security checklist
One short email a week: a practical check for apps built with Supabase, Next.js and AI tools (RLS, leaked keys, headers and more). Unsubscribe anytime.
AI builders often get a chat or "generate" feature working by calling the AI provider straight from the browser. It works in the preview, so it ships. But the key that request needs is now in your site's JavaScript, and anyone who opens DevTools can copy it and run requests billed to you. You won't notice until the invoice or the usage alert arrives.
This guide is for OpenAI, Anthropic (Claude) and Google Gemini keys. The same steps apply to any paid API key: Groq, OpenRouter, ElevenLabs, Replicate and the rest.
Check in 2 minutes
- Open your live site (not the editor preview), then DevTools → Sources (Chrome) or Debugger (Firefox).
- Search all files (Ctrl/Cmd + Shift + F) for:
sk-proj-,sk-ant-,sk-,AIza,api.openai.com,api.anthropic.com,generativelanguage.googleapis.com. - Then open the Network tab and use the AI feature. If you see a request going straight to
api.openai.com,api.anthropic.comorgenerativelanguage.googleapis.comfrom the browser, check its headers: anAuthorization: Bearer sk-…,x-api-keyorx-goog-api-keyheader (or akey=parameter) means the key is public.
A request to your own /api/chat or a Supabase edge function is the right shape. The key should only appear on the server side of that call.
The usual cause is a variable named VITE_OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY. Those prefixes mean "copy this into the browser bundle at build time". More on that: NEXT_PUBLIC_ and VITE_ variables are shipped to every visitor.
Fix it in this order
1. If you see usage you don't recognise, revoke now
Look at the provider's usage page first. If there's traffic you didn't make, delete the key immediately and accept that the AI feature is down until step 3. Then contact the provider's support with what you found.
2. Create a new key, and keep it on the server
Make a new key in the provider's dashboard and put it in a server-only environment variable: no VITE_ or NEXT_PUBLIC_ prefix. In Supabase that's an edge function secret; on Vercel, a normal environment variable used only by an API route.
3. Move the AI call behind your own route
The browser calls your route; your route calls the AI provider. The route should:
- Check the user is signed in, so it isn't a free AI proxy for the whole internet. Moving the key server-side without this just moves the problem.
- Rate-limit per user, and cap input size and output tokens.
- Decide the model and system prompt on the server. Don't let the browser pick an expensive model or send its own system prompt.
// app/api/chat/route.ts (Next.js) - the key never leaves the server
export async function POST(req: Request) {
const user = await getSignedInUser(req); // your auth check
if (!user) return new Response("Sign in first", { status: 401 });
// ...rate-limit by user.id here...
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}`, // no NEXT_PUBLIC_
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "YOUR_MODEL", input: String(message).slice(0, 4000) }),
});
return new Response(r.body, { status: r.status });
}4. Deploy, then delete the old key
Once the new version is live and working, delete the old key. Deleting it is the only thing that makes the copy in old bundles, caches and anyone's notes useless. Removing it from your code doesn't.
5. Put a ceiling on the damage
- OpenAI: give each app its own project and key, then set a monthly spend limit on the project and turn on Enforce a hard limit. OpenAI says alerts alone don't stop requests, and the hard limit isn't instantaneous. Keys can also be given an expiry date. OpenAI also says it disables keys it detects on the public internet, but don't count on that happening quickly. Source: OpenAI spend limits, Keeping your OpenAI account secure.
- Anthropic (Claude): delete the key from the API keys page in the Claude Console, check Logs and Usage, set a spend limit on the Billing page, and use a separate workspace with its own spend and rate limits for each app. You can't set spend limits on the Default Workspace. Source: Claude Help Center, Workspaces.
- Google Gemini: Google's leak checklist is: generate a new key, deploy with it, disable the old key in the Cloud console, then audit billing and API usage. New AI Studio keys are restricted to the Gemini API by default; restrict any older key that shows as Unrestricted. Source: Using Gemini API keys.
A prompt for Lovable, Bolt or Cursor
My app calls the AI provider (OpenAI / Anthropic / Gemini) from the
browser, so the API key is in my public JavaScript. Fix it:
1. Move every AI API call into a server route or Supabase edge function.
The browser should only call my own endpoint.
2. Read the key from a server-only secret. Remove any VITE_ or
NEXT_PUBLIC_ variable that holds it.
3. In that endpoint, reject requests from users who aren't signed in,
rate-limit per user, cap input length and max output tokens, and set
the model and system prompt on the server.
4. Search the whole codebase and the built output for the old key and
for direct calls to the provider's API, and show me what you found.
Don't put the key anywhere the browser can read it.Then repeat the DevTools check above on the live site. AI edits sometimes add a second, direct call somewhere else.
Don't stop at the AI key
If one secret ended up in the bundle, check for the others. Stripe secret keys (sk_live_) and the Supabase service_role key are worse than an AI key: they can move money or read every row in your database. See Supabase service_role key exposed? Do this in the next 15 minutes. And make sure your site isn't serving the files keys usually live in: .env, .git and source maps.
Keep it that way
Keys tend to come back with the next AI edit or the next new feature. Abbi Labs checks your live JavaScript for secret keys, along with Supabase tables readable with the public key, exposed .env/.git files and security headers. Monitor re-scans weekly and after every deploy, and emails you only when something new appears.
FAQ
Is it safe to put my OpenAI API key in a VITE_ or NEXT_PUBLIC_ variable?
No. Variables with those prefixes are written into the JavaScript every visitor downloads, so the key is public the moment you deploy. OpenAI, Anthropic and Google all say to keep API keys out of browser code. Call the AI provider from a server route or edge function instead.
Does hiding the key in an environment variable fix it?
Only if the code that uses it runs on the server. An env var that's read by browser code is copied into the bundle at build time. What matters is where the request to the AI provider is made, not where the key is stored.
Should I delete the key or just rotate it?
Create the new key first, move the call server-side and deploy, then delete (revoke) the old key. If you see usage you don't recognise, revoke the old key straight away even if the feature breaks for a while: every minute it's live costs you money.
Will a spend limit protect me?
It caps the damage, but it isn't a fix. OpenAI says alerts alone don't stop traffic and that a hard limit isn't instantaneous, so spend can slightly exceed it. On Anthropic you can set a monthly limit on the organization and on each workspace except the Default Workspace. Set one anyway, after you've moved the key.
Does Abbi Labs check for this?
Yes. The free quick check on the home page (no sign-up) downloads your home page and the JavaScript files it loads from your own domain and looks for key patterns including OpenAI (sk-, sk-proj-), Anthropic (sk-ant-), Google API keys (AIza), Stripe secret keys, AWS access keys, GitHub tokens and Supabase service_role or sb_secret_ keys. It shows only a masked prefix and suffix, never the full key. It can't see keys that never reach the browser, and it can't tell whether your server route checks who's calling it.
Related guides
- NEXT_PUBLIC_ and VITE_ variables are shipped to every visitor
- Supabase service_role key exposed? Do this in the next 15 minutes
- 3 files your production site shouldn't be serving: .env, .git and source maps
- Lovable app security checklist (from a security engineer)
- How to check if your Supabase tables are readable by anyone
- Supabase "42501 permission denied for table": fix it with a GRANT
- Security headers for Lovable, Bolt, v0 and Cursor apps