Skip to content
Kamran Code

Your AI API Key in the Frontend Bundle: How It Gets There, How to Check, How to Fix It

A VITE_ or NEXT_PUBLIC_ variable ends up in JavaScript every visitor can read. Here is how an AI key gets into a bundle, how to check your own app safely, the server-side fix, and what to do if it leaked.

Author
Kamran Khan
Published
Reading time
15 min read
On this page
  1. How a key ends up in a bundle
  2. What the Cyble report claims, and what it does not show
  3. Why a copied key keeps working
  4. How to check your own app
  5. Search the build output
  6. Search the live site
  7. Check git history
  8. The fix: keep the key on a server
  9. What to do if a key leaked
  10. Where the key lives, who can read it, and the fix
  11. What I could not verify
  12. Further reading

If an AI API key is in your frontend code, anyone who opens your site can copy it, and the copy works until you revoke the key. The fix is to keep the key on a server, have that server check who is asking and cap what they can spend, and revoke any key that has already shipped.

This is the written version of a 60-second YouTube Short. Where something is documented I link the page. Where it is my own reasoning I say "my advice". Where I could not check something, a section near the end lists it.

Your AI API key in the frontend is public (1-minute video)
Transcript

If your AI key is in your frontend code, it's a key card left on a table outside the club. Anyone walking past can copy it. Vite's docs say variables with the VITE prefix are exposed in client code after bundling, and shouldn't hold API keys. Next.js does the same with NEXT PUBLIC. In February, Cyble, a security firm, reported over five thousand public repositories and three thousand live sites leaking ChatGPT keys. Their count. They say the keys are abused, but show no proof and no method. The fix is a door. The browser asks your server, your server decides who gets in, then it calls the model. That last part is my advice. A server with no checks is the same hole, so cap the spend too. To check, search your built files for the first few characters of your key, never the whole key. A clean search isn't proof: keys can be split or encoded. My advice. If one leaked, revoke it and make a new one that stays on the server. GitHub scans repositories, not live sites, so a deployed bundle isn't covered. Change the locks. Don't just hide the card.

Watch the Short on YouTube: https://youtube.com/shorts/f-i7BQbuSyU.

How a key ends up in a bundle

Build tools let you put values from a .env file into your code. That suits harmless settings such as a public analytics ID. The same mechanism ships the value to the browser, so a key placed there is no longer secret.

Vite's documentation says: "Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling." It adds that VITE_* variables "should not contain sensitive information such as API keys", and that for production you should "consider a backend server or serverless/edge functions to properly secure secrets." The page shows version v8.3.4 and has no date; I read it on 14 October 2026.

Next.js works the same way with a different prefix. Its environment variables guide, last updated 25 August 2026, says variables without NEXT_PUBLIC_ "are only available in the Node.js environment". To expose one, Next.js can inline the value at build time into the JavaScript bundle delivered to the client. Of a NEXT_PUBLIC_ value it says: "It will be inlined into any JavaScript sent to the browser." The Next.js page does not say "no API keys". That conclusion is mine.

# .env (do not do this)
VITE_OPENAI_API_KEY=sk-...
NEXT_PUBLIC_ANTHROPIC_API_KEY=sk-ant-...

The prefix is the mistake. Hardcoding a key in a component, or sending it to the browser from your own endpoint, leaks it too, but the prefixes are what the documentation addresses.

The SDKs add a second warning. Anthropic's TypeScript SDK README says browser use is "disabled by default to avoid exposing your secret API credentials", and you enable it with dangerouslyAllowBrowser. OpenAI's Node SDK README uses the same flag. Turn it on in code a visitor loads and the key has to be in the browser for the call to work.

What the Cyble report claims, and what it does not show

On 12 February 2026 the security firm Cyble published "When AI Secrets Go Public: The Rising Risk of Exposed ChatGPT API Keys". I read the full post on 14 October 2026. It says its researchers found "over 5,000 publicly accessible GitHub repositories and approximately 3,000 live production websites" leaking API keys through hardcoded source and client-side JavaScript. The 3,000 sites are the ones with keys in "client-side JavaScript bundles, static assets, or front-end framework files". The 5,000 repositories are source code on GitHub, not bundles, so only the sites match this post.

Read the numbers with care:

  • Cyble gives no method. Nothing says how it decided a site was live, whether it tested any key, whether the two groups overlap, or what period it covers.
  • The counts are places where keys were found, not distinct keys. Adding them into "8,000 keys" is not something Cyble's numbers support.
  • It asserts abuse without evidence. The post says exposed keys are "actively used in real time", but shows no evidence of use, and its figures illustrate exposures, not abuse.
  • Its conclusion mentions "tens of thousands of GitHub repositories", while the body says 5,000.
  • Cyble sells a threat-intelligence product, Cyble Vision, and the post promotes it.

What I take from it: keys do sit in public places, according to one vendor. How often, and whether anyone uses them, the post does not show.

Why a copied key keeps working

The Short uses a picture: a key card left on a table outside a club. A card is copied, not taken. The original stays on the table, and the copy works every night until you change the locks. A visitor does not need to break anything. Reading the page is enough, and nothing in your app notices.

How to check your own app

Every step here is my advice. Each rests on the documented fact that the value is inlined into the built files, but the commands and their order are mine.

Search the build output

Build for production, then search for key prefixes. This finds keys with the sk-proj-, sk-svcacct- and sk-ant- prefixes, not just yours, and prints file names only, so no key appears on screen:

npm run build
grep -rEl 'sk-(proj|svcacct|ant)-' dist/          # Vite
grep -rEl 'sk-(proj|svcacct|ant)-' .next/static/  # Next.js

Use out/ instead if you export Next.js as static files. Do not search .next/server/, because server-only keys are meant to be there. The prefixes sk-proj- and sk-svcacct- are the ones Cyble shows as examples, and Anthropic's authentication page shows its keys starting sk-ant-api.

Older OpenAI keys start with a plain sk-, and admin keys may use another prefix, so the pattern above misses them. A broader search is grep -rEl '(^|[^A-Za-z0-9])sk-[A-Za-z0-9_-]{20,}' dist/, which also prints file names only but may give false positives. The legacy shape is my observation, not something I read in a page.

To look for one particular key, search the first few characters of it, such as grep -rl "sk-proj-a1b2" dist/. That finds only the key you typed, so an older, staging or teammate key will be missed. Never search for the whole key. It would sit in your shell history.

A match means the key is in the files you would deploy. A clean result proves little. A key can be split into pieces, base64-encoded, or fetched from your own API at runtime. Next.js documents that lookups built at runtime, such as process.env[name], are not inlined, so those stay out of the bundle and out of reach of this search. Precompressed .gz or .br files will not match a plain grep either, though the .js file does.

Search the live site

The deployed site may differ from your local build. Open it, open DevTools, and search all sources (Cmd+Option+F on a Mac, Ctrl+Shift+F elsewhere) for the same prefix. Then open the Network tab, trigger an AI feature, and look for requests going from the browser straight to api.openai.com or api.anthropic.com. A key in an Authorization or x-api-key header means it is client-side, whatever the search found. This only covers pages and features you actually triggered.

Check git history

git log --all --oneline -S "sk-proj-a1b2"

The -S option lists commits that changed how many times a string appears, which finds the commit that added a key and the one that removed it. A hit means the key was committed at some point, so treat it as exposed if the repository was ever public or cloned. A miss says little: it ignores branches you have not fetched, other repositories, and keys set only in a host dashboard.

Finally, read your .env files and host settings for any VITE_ or NEXT_PUBLIC_ name that contains OPENAI, ANTHROPIC or KEY. It is the quickest check and it finds the cause.

The fix: keep the key on a server

The documented part is short. Vite points you to a backend or serverless function, and Next.js keeps unprefixed variables on the server. What that server checks is my advice, and it matters. A route that forwards any request with your key attached moves the hole one step along. Anyone can call it and spend your money without reading your bundle.

So the route should confirm who is asking, refuse when they are over budget, and cap each reply. Here is a minimal Next.js route handler. I checked its shape against the Next.js Route Handlers page (v16.4.0, updated 27 August 2026) and the OpenAI Node SDK README. getUserId, reserveBudget, settleBudget and refundBudget are placeholders for your own login and database, and I have not run this code.

// app/api/chat/route.ts
import OpenAI from "openai";
 
// OPENAI_API_KEY has no NEXT_PUBLIC_ prefix, so it stays on the server.
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
 
// Integer millionths of a dollar. Input bound x input price + 1,000 x output price.
// Statuses where the provider rejected the request before doing any work (my list; see the companion post).
const REFUNDABLE = [400, 401, 403, 404, 413, 422, 429];
const WORST_CASE = 30_000;
 
export async function POST(request: Request) {
  // Cookie-authenticated POSTs can be sent cross-site, so check the origin.
  if (request.headers.get("origin") !== process.env.APP_ORIGIN) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
  const userId = await getUserId(request); // your login check
  if (!userId) return Response.json({ error: "Not signed in" }, { status: 401 });
 
  let prompt: unknown;
  try {
    ({ prompt } = await request.json());
  } catch {
    return Response.json({ error: "Bad JSON" }, { status: 400 });
  }
  if (typeof prompt !== "string" || prompt.length > 4000) {
    return Response.json({ error: "Bad input" }, { status: 400 });
  }
 
  // Reserve the worst case before the call, settle the real cost after.
  if (!(await reserveBudget(userId, WORST_CASE))) {
    return Response.json({ error: "Daily limit reached" }, { status: 429 });
  }
  let response;
  try {
    response = await client.responses.create({
      model: process.env.MODEL_ID!,
      input: prompt,
      max_output_tokens: 1000, // cap every reply
    });
  } catch (e) {
    // Refund only if the provider clearly rejected the request (my advice).
    if (e instanceof OpenAI.APIError && REFUNDABLE.includes(e.status ?? 0)) {
      await refundBudget(userId, WORST_CASE);
    }
    return Response.json({ error: "Model call failed" }, { status: 502 });
  }
  // Outside the try: never refund after a successful, billed call.
  // If this throws, log and alert, and keep the reservation.
  await settleBudget(userId, WORST_CASE, response.usage);
  return Response.json({ text: response.output_text });
}

The refund rule is the one in the companion post: keep the reservation unless you know the provider rejected the request: a status from that short list, not every 4xx and not a timeout. The browser calls fetch("/api/chat", ...) and never sees a provider key. A Vite app needs a separate server or serverless function, because Vite has nowhere to hold a secret.

With Anthropic the call is similar. Anthropic's README shows import Anthropic from '@anthropic-ai/sdk' and client.messages.create with model, max_tokens and messages, and the client reads ANTHROPIC_API_KEY. The README only logs message.content. The API reference lists text among several block types, so I take the first block whose type is text:

// inside your route handler
const message = await client.messages.create({
  model: process.env.MODEL_ID!,
  max_tokens: 1000,
  messages: [{ role: "user", content: prompt }],
});
const block = message.content.find((b) => b.type === "text");
const text = block && block.type === "text" ? block.text : "";

I used process.env.MODEL_ID because model names change faster than blog posts. The reserve step comes first because checking only "has this user spent less than their limit" lets a user at 49 cents of 50 still get a reply that costs more, and parallel requests overshoot further. That is my advice. The arithmetic, and why integers, are in my companion post, cap AI spend per user. A Turnstile check at sign-up is another layer, but it filters bots and does not limit spending.

What to do if a key leaked

  1. Revoke the key at once. OpenAI's safety guidance says to "revoke it promptly and replace it with a new key". Anthropic says to disable or delete any key you suspect has leaked. Disable is reversible and delete is permanent. For a key that was public, I would delete it.
  2. Create a new key and put it only on the server, in a variable without a public prefix. If it goes into the same VITE_ or NEXT_PUBLIC_ variable, it leaks again.
  3. Review usage for the exposure period in each provider's dashboard.
  4. Remove the key from code, rebuild and redeploy. Next.js documents that a built app does not respond to later changes in these variables, so the old build keeps the old value until you rebuild.

That is the rule for a leak. Routine rotation is different: OpenAI's production guidance says to revoke the old key once you have verified that the replacement works. Do that on a calm day. For a leaked key, expect some downtime between revoking and redeploying, and accept it.

Deleting a file or a commit does not help, because git keeps history and anyone with a clone has the old commits. A website behaves the same way: an older deployment, a cache or a browser that already loaded the file keeps a copy. Once a key has been public, treat it as burned.

On GitHub: secret scanning is not limited to public repositories. GitHub's pattern list covers OpenAI and Anthropic API keys, and scanning is free on public repositories. On private ones it needs GitHub Secret Protection, which GitHub documents for organisation-owned repositories on Team or Enterprise Cloud. But the partner notification, where GitHub tells the provider about a match, runs by default on public repositories and public npm packages. GitHub's docs say a partner can extend its alert service to revoke secrets, and I found no promise from either provider that it will. GitHub's pages describe scanning repositories and public npm packages, and I found nothing about scanning live websites, so I would not count on it for a deployed bundle. Do not wait for anyone to revoke it for you.

A restricted or scoped key is still public if it is in the bundle. Anthropic documents keys scoped to a workspace, and workspaces separate environments and control spend. That limits what a copy can do, but it does not make shipping the key safe.

Where the key lives, who can read it, and the fix

Where the key livesWho can read itFix
VITE_ variableAnyone who loads the pageMove to a server or serverless function
NEXT_PUBLIC_ variableAnyone who loads the pageRemove the prefix and call from a route handler
Hardcoded in a componentAnyone who loads the page or the repositoryRemove, revoke, move to the server
Server variable, no public prefixYour server and host settingsKeep it, and add login, budget and token caps
Committed to a repositoryAnyone with access, and scannersRevoke first, then remove from code
Old commit in git historyAnyone with a cloneTreat as burned and revoke
Server route with no checksNo one reads the key, but anyone can spend itAdd login, budget and token caps

What I could not verify

  • Cyble's method, because the post does not give one. I cannot say how sites were judged live, whether keys were tested, or whether the counts overlap.
  • Whether any exposed key was used. Cyble asserts it and shows no evidence.
  • Whether OpenAI or Anthropic revoke keys GitHub reports. I found no promise.
  • The exact usage-review path in either dashboard.
  • That my code runs. I checked the call shapes against documentation but did not execute the examples.
  • Whether Vite inlines lookups built at runtime. Only the Next.js page documents that.

Further reading

Frequently asked questions

Does changing a NEXT_PUBLIC_ variable fix a deployed app?

No. Next.js replaces those references with the value at build time, and its documentation says a built app no longer responds to changes in them. After you revoke a leaked key you must also rebuild and redeploy without it, otherwise the old build keeps serving the old value.

Should I disable or delete a leaked Anthropic key?

Anthropic's authentication page says to disable or delete any key you suspect has leaked. Disabling is reversible and deleting is permanent. For a key that was public, I would delete it, because there is no reason to bring a copied key back. That is my advice.

Is it safe to use dangerouslyAllowBrowser for a personal project?

The flag only removes the SDK's check. Anthropic's TypeScript SDK README says browser use is disabled by default to avoid exposing your secret API credentials, and OpenAI's Node SDK README says the same. OpenAI's README lists a few lower-risk cases, such as trusted internal tools or short-lived credentials. A public website is not one of them.

Will an expiry date on the key help?

It limits how long a leaked key stays usable. Anthropic offers presets from 3 hours to 30 days, a custom duration, or no expiry, but its own documentation says expiry is not a substitute for secret hygiene. A key that expires in a day can still be copied and used for that day.

Why does my grep find nothing when I know I used a public variable?

Next.js documents that lookups built at runtime, such as process.env[name], are not inlined, so the value may never reach the bundle that way. That is a reason to be glad, but it is also a reason a clean search proves little. The key could be loaded from your own endpoint at runtime, or built from pieces.

Does a restricted key make it safe to ship in the browser?

No. A restricted key limits the damage, and Anthropic documents workspace-scoped keys and OpenAI has project keys, but anyone can still copy a key that is in the bundle and use it within its limits. Restriction is a second layer behind keeping the key off the page.

  • #api-keys
  • #openai
  • #anthropic
  • #vite
  • #nextjs
  • #security
  • #environment-variables
  • #secrets

Kamran Khan

I build AI-powered applications, full-stack platforms and business software — across full-stack development, business systems, automation and production AI.

About Kamran →

Keep reading

  • AI Engineering20 min read

    AG-UI explained: what it is and how it differs from MCP

    AG-UI is an open event protocol for streaming an AI agent to a frontend. What it is, how a run looks on the wire, how it differs from MCP, and when to skip it.

    • #ag-ui
    • #ai-agents
    • #agent-protocols
    • #sse
  • AI Engineering18 min read

    Capping AI Spend per User: Denial of Wallet and a Practical Design

    A bot check decides who gets in, not how much they spend. A source-checked look at denial of wallet, what OpenAI and Anthropic do and do not cap, and a per-user budget design with a reserve and settle sketch.

    • #denial-of-wallet
    • #llm-cost-control
    • #rate-limiting
    • #owasp
  • AI Engineering12 min read

    What Is Jev? TypeSafe AI's Decision Model, Explained

    Jev from TypeSafe AI answers questions you define with typed choices and probabilities instead of text. How it works, what it costs and where it breaks.

    • #jev
    • #jev-ai
    • #typesafe-ai
    • #system-one

Turn an AI idea into working software.

From retrieval and agents to document intelligence, I build AI systems that plug into real business data and workflows.