Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

OpenAI API key exposed in the frontend: how to fix it

FIX / OPENAI KEY LEAK / 11 MIN READ

An OpenAI API key in your frontend code is public, so delete it in the OpenAI dashboard now and create a new key that only your server can read. Every visitor to your site can copy a key from the browser. No build setting can hide it. The only fix is to call OpenAI from server code and keep the key there.

This guide shows how people find the key, how you confirm the leak, and how you stop it. It also shows how to check the damage and how to build a server route with auth and rate limits.

What does “OpenAI API key exposed in frontend” mean?

It means that the key is part of the files the browser downloads. Those files are the HTML, the JavaScript bundle, and any source maps. The browser must read them to run your app, so every visitor can read them too.

Vibe coding tools often create this problem. You ask the tool for a chat feature. The tool writes a fetch call to api.openai.com in a React component. The call needs a key, so the tool adds a variable such as VITE_OPENAI_API_KEY. The app works in the browser, and the leak stays hidden.

The prefix on the variable name causes the leak. The build tools copy prefixed variables into the bundle on purpose:

Framework Prefix What the docs say
Vite VITE_ VITE_* variables should not contain sensitive information such as API keys.
Next.js NEXT_PUBLIC_ The value is inlined into the JavaScript sent to the browser at build time.

OpenAI gives the same rule in its API reference. The authentication page says: “Don’t share it with others or expose it in any client-side code such as browsers or apps.”

The key is not the only risk. The request that the browser sends is also public. An attacker does not need to find the key in the bundle. The attacker can open the network tab and copy it from the request.

How can anyone read my OpenAI key?

Anyone can read the key in two ways. Both take less than a minute and need no special tools.

Method 1: The network tab

  1. Open your live site in a browser.
  2. Open DevTools and select the Network tab.
  3. Use the AI feature of the app once.
  4. Select the request to api.openai.com.
  5. Read the Authorization header. It has the form Bearer followed by the key.

If your app calls your own server route first, you do not see this request. That is the safe design. If you see the request go to api.openai.com from the browser, the key is public.

Method 2: Search the bundle

  1. Open DevTools and select the Sources tab, or open the bundle URL in a new tab.
  2. Search all files for api.openai.com.
  3. Search for the first characters of your key. You can see these characters in the OpenAI dashboard.
  4. Search for Authorization and Bearer.

You can also download the bundle and search it from a terminal:

curl -s https://your-app.example/ | grep -o 'src="[^"]*\.js"'
curl -s https://your-app.example/assets/index-abc123.js | grep -o 'Bearer [^"]*'
curl -s https://your-app.example/assets/index-abc123.js | grep -c 'api.openai.com'

Replace the URLs with the real script paths from the first command. A count above zero means the bundle calls OpenAI directly. Do not paste the key into a chat, a ticket, or a public repository when you check.

Source maps make the search easier, because they restore the original file names and variable names. See source maps and exposed git folders for how to check if your site serves them.

Automated bots also scan public sites and public repositories for strings that look like API keys. Assume that a key in a public bundle is already copied.

How do I confirm that my key leaked?

Use these four checks. Run them in order.

  1. Check the live site. Use the two methods above on your production URL, not on your local build. Check every page that has an AI feature.
  2. Check the build settings. Open the environment variables of your host (Vercel, Netlify, Lovable, Bolt, Replit) and look for names that start with VITE_ or NEXT_PUBLIC_ and contain OPENAI.
  3. Check the code. Search your repository for api.openai.com, new OpenAI(, and dangerouslyAllowBrowser. The OpenAI JavaScript library refuses to run in a browser by default. The dangerouslyAllowBrowser option turns that protection off. If an AI tool added it, the key is in the browser.
  4. Check the history. Search the git history for the key. A key that you removed in a later commit stays readable in the old commits.

Warning: Do not test the key by sending requests with it from a public tool. Each request costs money and appears in your usage logs.

If any check finds the key, treat the key as leaked. Start the fix now.

What do I do first? Fix it now, in order of urgency

Do the steps in this order. Steps 1 and 2 stop the loss. The other steps remove the cause.

Caution: If you delete the key before you deploy a fix, the AI feature of your app stops working. This is the correct result. A broken feature costs less than an unlimited bill.

  1. Delete the leaked key. Open the OpenAI dashboard and go to the API keys page. Find the key that your frontend uses and delete it. A deleted key stops working at once. If you cannot find the key in the list, check the legacy user keys page in your account.
  2. Set a spend limit on the project. In the project settings, select Limits. Then select Edit spend limit, enter a monthly amount, and turn on Enforce a hard limit. The steps are in the OpenAI spend limits guide. After the project reaches the limit, requests fail with a 429 error.
  3. Create a new key in a separate project. Use a project that only your AI feature uses. If the new key leaks later, the damage stays inside that project.
  4. Store the new key as a server-side secret. Use a variable name without a public prefix. Use OPENAI_API_KEY, not VITE_OPENAI_API_KEY.
  5. Move the OpenAI call to a server route. See the next section.
  6. Remove the old variable and the old code. Delete the VITE_ or NEXT_PUBLIC_ variable from your host and from your .env files. Delete the browser-side OpenAI code.
  7. Rebuild and deploy. The old key stays in any bundle that was built before the change. A new build replaces it.
  8. Check the live bundle again. Repeat the search from the confirm section on the new deployment.

Spend alerts are a separate control. According to the OpenAI spend limits guide, alerts send notifications and do not stop traffic. Set an alert below the hard limit. Then you get a warning before the limit stops the feature.

Note: The hard limit is not instant. The recorded spend can go slightly above the limit.

How do I check the damage after an OpenAI key leak?

Read your usage data for the period in which the key was public. Look for use that you did not make.

  1. Open the usage page. In the OpenAI dashboard, go to Settings, then Organization, then Usage. Select the project that held the leaked key.
  2. Compare with your real traffic. Look at the daily cost and the number of requests. Find days where the numbers are much higher than your users could cause.
  3. Check the models. Look for models that your app never calls. An attacker often chooses the most expensive model, or a model that your app does not use.
  4. Check the key identity. The usage data can show which key made the request. Match the key name to the key you deleted.
  5. Check other projects. If you used the same key value in more than one app, check each one.
  6. Check your own logs. Read the logs of your host for the period of the leak. Look for sudden bursts of traffic to your site.

Also check what else was in the same bundle. A VITE_ or NEXT_PUBLIC_ variable is not the only secret that tools place in the browser. Look for database passwords, service_role keys, payment keys, and webhook secrets. If you find a Supabase service_role key, read what to do about a leaked service_role key.

Note: OpenAI usage data is not a security log of every request. It shows cost and volume, not the content of prompts. If your app stores user data in prompts, ask yourself whether an attacker could use your key for anything other than cost. A key alone does not give access to your users’ data in your own database. It does let an attacker spend your credit and use your quota.

If you see charges that you did not cause, contact OpenAI support and give the project ID, the dates, and the deleted key name.

How do I hide the OpenAI API key in React?

You cannot hide the key in React. A React app runs in the browser, so everything inside it is public. To keep the key private, the browser must not hold the key at all.

The correct design has three parts:

  1. The browser sends the user prompt to your server route.
  2. The server route checks who the user is, checks their usage limit, and adds the OpenAI key.
  3. The server route calls OpenAI and returns the result.

The browser never sees the key. The server code can use any private storage that your host provides.

The route must do more than forward the request. A route that accepts any caller and forwards everything is an open proxy. An attacker then uses your key through your own URL. The route needs these controls:

Control What it stops
Authentication Anonymous callers who use your key
Rate limit per user One user who sends thousands of requests
Fixed model and fixed parameters A caller who selects an expensive model or a very long output
Input size limit Very long prompts that cost a lot
Project spend limit Any remaining loss when other controls fail

Option A: Supabase Edge Function

First store the key as a secret. The Supabase secrets documentation gives this command:

supabase secrets set OPENAI_API_KEY=your-new-key

Do not type the real key into a shared terminal history or a chat. Run supabase secrets list to confirm the name exists. Deployed functions use new secrets at once, so you do not need to deploy again.

Edge Functions check for a JWT by default. The Supabase function auth documentation states that the default is verify_jwt = true. Note that the public anon key is also a valid JWT. For this reason, check the signed-in user inside the function with auth.getUser().

Create supabase/functions/ask-ai/index.ts:

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

const cors = {
  "Access-Control-Allow-Origin": "https://your-app.example",
  "Access-Control-Allow-Headers": "authorization, content-type, apikey",
};

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

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
  );

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401, headers: cors });

  const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
  const { count } = await supabase
    .from("ai_calls")
    .select("*", { count: "exact", head: true })
    .gte("created_at", since);
  if ((count ?? 0) >= 20) {
    return new Response("Rate limit exceeded", { status: 429, headers: cors });
  }
  await supabase.from("ai_calls").insert({ user_id: user.id });

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

  const upstream = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
    },
    body: JSON.stringify({
      model: "gpt-4o-mini",
      input: prompt,
      max_output_tokens: 500,
    }),
  });

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

The function uses the user’s token for the database calls. For this reason, the rate-limit table needs Row Level Security (RLS) policies. Each user must insert and read only their own rows:

create table public.ai_calls (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users (id),
  created_at timestamptz not null default now()
);

alter table public.ai_calls enable row level security;

create policy "own rows insert" on public.ai_calls
  for insert to authenticated
  with check (user_id = (select auth.uid()));

create policy "own rows select" on public.ai_calls
  for select to authenticated
  using (user_id = (select auth.uid()));

Set the allowed origin in cors to your real domain. Do not use * for a function that spends money.

Call the function from the browser with the Supabase client. The client sends the user token for you:

const { data, error } = await supabase.functions.invoke("ask-ai", {
  body: { prompt },
});

Option B: Next.js route handler

Put the key in a variable without the NEXT_PUBLIC_ prefix. Next.js keeps these variables on the server. Create app/api/ask/route.ts:

import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const user = await getUserFromSession(req); // your auth check
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  if (!(await underLimit(user.id))) {
    return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
  }

  const { prompt } = await req.json();
  if (typeof prompt !== "string" || prompt.length > 2000) {
    return NextResponse.json({ error: "Bad request" }, { status: 400 });
  }

  const upstream = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({ model: "gpt-4o-mini", input: prompt, max_output_tokens: 500 }),
  });

  return new NextResponse(upstream.body, {
    status: upstream.status,
    headers: { "Content-Type": "application/json" },
  });
}

The functions getUserFromSession and underLimit are placeholders. Use the session check of your auth library. For the limit, use a counter in your database or a rate-limit service. Vercel Functions and other serverless hosts work in the same way. Set OPENAI_API_KEY in the project settings of the host, as a server variable.

Do not use a server route that forwards raw requests

Avoid this design: the browser sends the full OpenAI request body, and the server only adds the key. The caller then controls the model, the tools, and the output length. Accept only the prompt from the browser. Set all other parameters on the server.

How do I stop the next leak?

Use these controls so that a key never reaches the browser again.

  1. Name secrets without a public prefix. Do not use VITE_, NEXT_PUBLIC_, REACT_APP_, or similar prefixes for anything secret. Only values that are safe for every visitor can have these prefixes.
  2. Give each app its own OpenAI project and key. A leak in one app then does not affect the others. Set a separate hard spend limit in each project.
  3. Set an expiry date on keys. The OpenAI production best practices recommend that you set an expiration date when you create a project API key and that you rotate keys on a regular schedule.
  4. Keep keys out of the repository. Add .env* files to .gitignore. The create-next-app template does this for you.
  5. Review AI-generated code for direct calls. Search each pull request for api.openai.com, dangerouslyAllowBrowser, and fetch calls with an Authorization header in client files. Tell your coding tool in the project rules to never call a paid API from the browser.
  6. Scan the deployed app after each release. Check the real bundle, not the source. Your host can inject variables at build time that you do not see in the repository. A scan of the live site finds these.
  7. Limit the cost of the route itself. Add the per-user rate limit, the input size limit, and a maximum for output tokens. See API abuse protection for more patterns.

For more on build-time and runtime variables, read environment variables security.

Does the same problem apply to Anthropic and other LLM keys?

Yes. The rule does not depend on the vendor. A key that can spend money must stay on the server. This applies to Anthropic, Google Gemini, Mistral, Cohere, Groq, ElevenLabs, image APIs, and any other paid API.

The steps are the same:

  1. Delete the leaked key in the dashboard of the vendor.
  2. Set a spend limit if the vendor offers one.
  3. Create a new key and store it as a server secret.
  4. Move the call to a server route with auth, rate limits, and fixed parameters.
  5. Check the usage page for charges that you did not cause.

The server code changes only in the URL, the header names, and the body format. Read the authentication page of each vendor for the exact header. The design of the proxy stays the same.

Some vendors offer short-lived tokens for browser use. These tokens are not the same as a long-lived secret key. Use them only if the vendor documents them for client-side use, and keep the lifetime short.

When should I get help?

Get help in these cases:

  • The usage page shows large charges that you cannot explain. Contact OpenAI support with the project ID, the dates, and the key name.
  • The same bundle also holds other secrets, such as database credentials or payment keys. Delete each one and check its own logs.
  • Your app stores personal data and you do not know who else read the leaked key. Ask a security engineer to review the app. You may also have legal duties to report a breach.
  • You cannot find where the key enters the build. A fresh scan of the deployed app shows what the browser receives.
  • The leak happened in a public repository. Delete the key first. Then remove the key from the git history, and treat any copy as permanent.

After you finish the fix, run the live-site checks one more time. Open the network tab, use the AI feature, and confirm that the browser sends the request only to your own server route.

Common questions

Is an OpenAI key in a VITE_ or NEXT_PUBLIC_ variable safe?
No. Both prefixes tell the build tool to copy the value into the browser bundle. Anyone who opens your site can read the key.
Can I hide the key if I minify or obfuscate the code?
No. The browser must send the key in the Authorization header on every request. Anyone can read that header in DevTools.
Do I need to delete the key if I see no unusual usage?
Yes. A key that was public can be copied at any time, and the copy can stay unused for a long time. Delete the key and create a new one.
Does this problem apply to Anthropic and other LLM keys?
Yes. Every provider key that can spend money must stay on the server. The same steps work for Anthropic, Google, Mistral, and others.
Can a hard spend limit replace the server route?
No. A limit caps the cost, but an attacker can still use your key until the cap. The attacker can also cause 429 errors for your real users.

Find keys in your bundle

A VibeEval scan loads your deployed app, reads the JavaScript the browser downloads, and reports secret keys that the bundle contains.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →