API key exposed in frontend: which keys are safe, and how to fix a leak

Any API key your frontend can read, every visitor can read: Vite, Next.js and Expo paste VITE_, NEXT_PUBLIC_ and EXPO_PUBLIC_ variables into the JavaScript at build time. Supabase publishable keys, Firebase web config and Stripe pk_ keys are built for that. AI provider keys, Stripe secret keys, Supabase service role and AWS keys are not: move those calls to a server route, and rotate any that already shipped.

The team behind Keybrum's attacksUpdated 14 min read

In short

  • An environment variable keeps a key out of Git, not out of the browser. A VITE_, NEXT_PUBLIC_ or EXPO_PUBLIC_ value is pasted into the bundle at build time.
  • Ask one question per key: does it only identify your project, or does it act as you? Supabase publishable keys, Firebase web config and Stripe pk_ keys identify. OpenAI, Anthropic, Stripe sk_ and rk_, Supabase service role and AWS keys act.
  • Public Google API keys can turn into secrets: in February 2026, Truffle Security reported 2,863 live keys on public web pages that could call Gemini because the Gemini API was enabled in their project.
  • The fix is a server route that holds the key, checks that the caller is signed in, limits how often each user can call it and caps what each call can cost.
  • If a secret key already shipped, rotate it before anything else. Deleting it from the code or the Git history does not take it back.
On this page

Why every API key in your frontend is public

Your frontend runs on the visitor's computer. To run it, the browser downloads the HTML, the JavaScript and every value baked into that JavaScript. If your React code reads an API key, that key sits in a file anyone can open with the developer tools. An .env file does not change this. It keeps the key out of your Git repository, but the build tool reads the file and pastes the value into the bundle.

Each framework marks the variables it will paste with a prefix. A variable without the prefix stays on the server or on the build machine. A variable with it ships to every visitor:

FrameworkPrefix that shipsWhat happens at build timeWhat its docs say
Vite (Lovable apps created before May 2026, many React apps)VITE_Values appear on import.meta.env and are bundled into your source codeVITE_* variables should not contain sensitive information such as API keys
TanStack Start (newer Lovable apps)VITE_ with ViteClient code can only read variables with the public prefixUnprefixed variables are for server code only
Next.jsNEXT_PUBLIC_Each process.env.NEXT_PUBLIC_... reference is replaced by the hard-coded value in the JavaScript sent to the browserValues are frozen at build time: changing one needs a new build
Expo (React Native)EXPO_PUBLIC_References are replaced by the value in the app bundleDo not store private keys there: they are visible in plain text in the compiled app
Create React App (older React apps)REACT_APP_Values are embedded into the buildDo not store any secrets, such as private API keys, in your React app
From the environment variable pages of the Vite, TanStack Start, Next.js, Expo and Create React App documentation, listed in the sources.

Two details catch people after they learn the prefix rule. In Vite, a define entry in vite.config is statically replaced during the build, so define: { 'process.env.OPENAI_API_KEY': JSON.stringify(env.OPENAI_API_KEY) } ships the key whatever its name. In Next.js, a Server Component that hands a secret to a Client Component as a prop sends it to the browser too: the Next.js data security guide warns that private data can leak through what you pass to components. The prefix is only the most visible way out.

Which API keys are safe to expose, and which never

Not every key in a bundle is a leak. Some providers design a key to be public and protect your data another way. Ask one question per key: does it only tell the provider which project you mean, or does it let the caller act as you?

ProviderFine in the frontendNever in the frontendWhat actually protects you
SupabasePublishable key sb_publishable_..., legacy anon keySecret key sb_secret_..., legacy service_role keyRow level security on every table. Secret and service role keys skip it.
FirebaseWeb config apiKey (starts with AIza), restricted to Firebase APIsAdmin SDK service account JSON, any key allowed to call the Gemini APISecurity Rules and App Check, not the secrecy of the key
Google MapsBrowser key restricted by website and by APIAn unrestricted key in a project where the Gemini API is enabledAPI and website restrictions on each key
StripePublishable key pk_live_...Secret key sk_live_..., restricted key rk_live_...Keeping sk_ and rk_ keys on your server
OpenAI, Anthropic, Gemini, other AI APIsNoneEvery API key (sk-proj-..., sk-ant-...)Nothing: whoever holds the key runs models on your bill
AWS, databases, emailNoneAccess keys (AKIA...), database URLs with a password, Resend or SendGrid keysNothing once the key is out
Not sure which Supabase key you have? Paste it in the Supabase key checker: it runs in your browser.

The AI SDKs say it themselves. The official OpenAI and Anthropic JavaScript SDKs refuse to run in a browser unless you set dangerouslyAllowBrowser: true, a default both describe as a way to avoid exposing your secret API credentials. If that flag is in your code, your key is public.

Supabase adds one guard for its new secret keys: it answers HTTP 401 when a secret key arrives from a browser, based on the User-Agent header. That catches a careless frontend call. It does nothing against a stranger: Supabase's own docs say an attacker can still use the key from other tools. The publishable key is a different story: it is meant to be public, as long as row level security is on. The Supabase anon key guide explains what it can reach. Supabase is deprecating the legacy anon and service_role keys by the end of 2026, so plan the move to publishable and secret keys.

What a stranger can do with a secret key from your bundle

A key in a JavaScript file is not hidden by being hard to find. Bundles are public, and they get archived and searched at scale: the research above ran over Common Crawl, about 700 TiB of public pages with their scripts. What a key gives depends on the provider:

  • AI provider keys (OpenAI, Anthropic, Gemini, OpenRouter): the holder runs models on your account until you revoke the key or a spend limit stops them. Truffle Security notes that a Gemini-enabled Google key can also reach files and cached content uploaded to Gemini.
  • Stripe secret keys: Stripe describes the sk_ key as having unrestricted permissions on all Stripe APIs, which covers your customers, payments and refunds.
  • Supabase service role or secret keys: every row and every stored file, readable and writable, with row level security skipped. Read what to do when the service role key leaks.
  • AWS access keys: whatever the IAM user behind them may do in your AWS account.
  • Email keys (Resend, SendGrid): messages sent in your name, from your domain.

None of this needs your app. The holder calls the provider directly, so your own logs show nothing. The first sign is usually the provider's usage page, or the invoice.

How to check your own live app for exposed keys

Check the files visitors download: the build is what ships, and it can differ from your source. These checks take a few minutes and only read your own site.

  1. 1

    Search every loaded file in the browser

    Open your live site, then the developer tools. In Chrome, press Ctrl+Shift+F on Windows and Linux or Cmd+Option+F on a Mac to open the Search panel, which searches every loaded file. Search for sk-proj-, sk-svcacct-, sk-ant-, sk_live_, rk_live_, sb_secret_, service_role, AKIA, PRIVATE KEY and postgres://. Visit the pages that load code later, like the dashboard after sign-in, because bundles are often split per page.

  2. 2

    Watch the Network panel

    Use the feature that calls the API with the Network panel open. A request that goes straight from the browser to api.openai.com or api.anthropic.com, or a call to api.stripe.com carrying an sk_ key, means the key is in the frontend, whatever the code looks like. Read the JSON your own API returns too: a config or debug route can hand out keys the bundle does not contain.

  3. 3

    Look for source maps

    Open one of your JavaScript files and add .map to its address. If a file comes back, your original source is public, with comments and file names, which makes any key or hidden route easy to read. Vite (build.sourcemap) and Next.js (productionBrowserSourceMaps) both leave production source maps off by default, so a map in production means someone turned them on.

  4. 4

    Search the build output on your machine

    Run your production build and search what it produced: dist/ for Vite, .next/static/ for Next.js, which is the folder served to browsers. The command below lists the files that contain a known secret prefix.

bash
npm run build
grep -rlE 'sk-proj-|sk-svcacct-|sk-ant-|sk_live_|rk_live_|sb_secret_|service_role|AKIA[0-9A-Z]{16}|PRIVATE KEY' dist .next/static 2>/dev/null
No output means none of these patterns is in the files you ship. Add the prefixes of the other services you use. A match can be a name in your code or in a library rather than a key, service_role especially: open the file and look.

How to hide an API key in the frontend: move the call to your server

You cannot hide a secret in code the browser runs. Obfuscation, base64 or encryption only slow a reader down, because the browser has to recover the real key to send it. The fix is to keep the key on a server and let the browser call your server instead. Here is the pattern to remove, from a Vite app, then the version to ship, as a Next.js route.

ts
// src/lib/ai.ts: this file runs in every visitor's browser
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: import.meta.env.VITE_OPENAI_API_KEY, // pasted into the bundle at build time
  dangerouslyAllowBrowser: true, // the SDK will not run in a browser without it
});
Before: the key ships to every visitor.
ts
// lib/openai.ts: server code only
import "server-only"; // build error if a Client Component imports this file
import OpenAI from "openai";

export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no NEXT_PUBLIC_ prefix

// app/api/chat/route.ts
import { openai } from "@/lib/openai";
import { getSessionUser } from "@/lib/auth"; // your auth helper
import { allowRequest } from "@/lib/rate-limit"; // your per-user limit

export async function POST(req: Request) {
  const user = await getSessionUser(req);
  if (!user) return new Response("Sign in first", { status: 401 });
  if (!(await allowRequest(user.id))) return new Response("Too many requests", { status: 429 });

  const { message } = await req.json().catch(() => ({}));
  if (typeof message !== "string" || message.length > 2000) {
    return new Response("Invalid message", { status: 400 });
  }

  const response = await openai.responses.create({
    model: "YOUR_MODEL", // picked by the server, never by the browser
    instructions: "You answer questions about this app.",
    input: message,
    max_output_tokens: 500,
  });
  return Response.json({ text: response.output_text });
}
After: the key stays in the server's environment. The browser sends only the user's text to /api/chat.

The browser now calls fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) }) and never sees the key. A plain Vite app has no server of its own, so the same code goes into a serverless function on your host or a Supabase Edge Function, with the key stored as a function secret. In TanStack Start, it goes into a server function, which reads unprefixed variables.

Moving the key is half the fix. A server route that answers anyone is the same leak one step removed: strangers call your route instead of OpenAI, and you still pay. Before you ship it, check each line:

  • The route rejects callers without a valid session, checked on the server, not by hiding a button.
  • Each user has a rate limit, and the provider account has a spend limit or usage alert, so a leak has a ceiling.
  • The server picks the model, the system prompt and the maximum output length. The browser only sends the user's text.
  • Input size is capped, so one request cannot carry a whole book.
  • In Next.js, modules that read secrets import server-only, which turns an accidental import from a Client Component into a build error.
  • The key lives in your host's server environment variables, without a public prefix, and nowhere in the repository.

The unprotected AI endpoint guide goes deeper on protecting the route itself.

The traps people miss after the obvious fix

The old bundle is still out there

Removing the key from the code and deploying again does not take back what already shipped. Browsers, CDN caches and public archives keep copies of old JavaScript files, and older deployments may still answer at their own address. Treat any secret that reached production as known, and rotate it.

Public variables are frozen at build time

Next.js inlines NEXT_PUBLIC_ values during next build, and Vite bundles VITE_ values at build time. Changing a variable in your host's dashboard does nothing until you build again. That matters when you rotate a public key such as a Supabase publishable key: the new value only reaches visitors with the next build.

Files and routes that serve keys directly

A clean bundle does not help if the server hands the keys out another way: an API route that returns a config object with every environment variable, a debug page that prints them, or an .env file served as a static file. The last one is the whole leak in one request, see the exposed .env file guide.

Mobile apps keep the leak longer

An EXPO_PUBLIC_ value sits in plain text in the compiled app, and you cannot recall a version already installed on people's phones. Rotating the key breaks that feature in old versions until users update. That is the price, and it is cheaper than leaving a live secret in an app anyone can download and unpack.

After a leak: rotate first, then fix the code

If a secret key was in your frontend, assume someone has it. Kill the leaked key before you write the clean fix: a feature that stops working for an hour costs less than a key that runs up a bill or reads your customers in the meantime.

  1. 1

    Revoke or rotate the key at the provider

    Do it in the provider's dashboard: the API keys page at OpenAI or Anthropic, IAM for AWS, Settings > API Keys in Supabase, the API keys page in Stripe. In Stripe, rotating a key creates a replacement you can use at once, but the old key stays valid until the date you pick in the Expiration list: choose Now for a key that leaked, since a later date keeps it working until then.

  2. 2

    Put the new key on the server only

    Store it as a server environment variable or function secret, without a VITE_, NEXT_PUBLIC_ or EXPO_PUBLIC_ prefix, and ship the server route from the section above. Never paste the replacement where the old key was: the next build would leak it again.

  3. 3

    Read the usage logs

    Look at the provider's usage or event logs from the day the key first shipped: model calls you did not make, refunds you did not issue, changed rows, new users in Supabase Auth.

  4. 4

    Set a ceiling for next time

    Add a spend limit or usage alert where the provider offers one, and prefer narrow keys. Stripe recommends restricted keys over secret keys to limit the damage of a leak, and a Google key can be limited to the one API it serves.

  5. 5

    Clean the Git history if the key was committed

    Deleting the file does not remove the key from past commits or forks. The rotation is what protects you. Cleaning the history only stops the next reader from finding a dead key.

The leaked API key guide goes through the rotation provider by provider.

What Keybrum checks on a live app

The free check reads your app from the outside, like any visitor. Paste the address: in about ten seconds it shows the backend, the keys exposed in your JavaScript and the tables it names. No account, and nothing is sent to your backend.

The full attack goes further, on an app whose ownership you have proved. It looks for secret keys in your bundle, from OpenAI, Anthropic and Gemini to Stripe, AWS and Supabase service role keys. For the providers that allow it, it checks each key with a free, read-only call, such as listing models or reading a Stripe balance, so the report says whether the key still works and not only that it looks like one. It also checks for served source maps and .env files and for AI routes that answer without a session, then attacks again after your fix.

Questions

How do I hide an API key in the frontend?

You cannot. Anything the browser runs, a visitor can read, and obfuscation or encryption only slows them down because the browser needs the real key to use it. Keep the key in a server route or serverless function that checks the user and calls the provider for them. The only keys that belong in a frontend are the ones designed to be public.

Are Vite env variables exposed to the browser?

Only the ones starting with VITE_, or your custom envPrefix: Vite bundles their values into your JavaScript at build time, and its docs say they should not contain API keys. Unprefixed variables stay out unless a define entry in vite.config copies them in. Vite throws an error on an empty envPrefix, which would expose everything.

Are NEXT_PUBLIC_ environment variables safe?

They are public, so they are only safe for public values: an analytics ID, a Supabase publishable key, a Stripe pk_ key. Next.js replaces each reference with the value in the browser JavaScript during next build. Variables without the prefix are only available on the server.

Is it safe to expose a Firebase API key?

Firebase says keys restricted to Firebase services do not need to be treated as secrets, because Security Rules and App Check protect your data. Use a separate key for any other Google API, and never allow the Gemini Developer API on a public key. The Firebase API key guide covers the checks.

Is a Stripe publishable key safe in frontend code?

Yes. Stripe says the pk_live_ key is safe to embed in your code or apps. Secret keys (sk_live_) and restricted keys (rk_live_) are for server code only, and Stripe recommends restricted keys there to limit the damage if one leaks.

Can someone find an API key in a minified JavaScript bundle?

Yes. Minification renames variables but leaves string values as they are, so a key reads the same in a minified bundle as in your source. Source maps only make the code around it easier to follow.

What should I do first if my API key was exposed?

Rotate or revoke it at the provider, before you touch the code. Then put the new key on a server only, check the usage logs since the key shipped, and set a spend limit. Removing the key from your code or Git history does nothing on its own.

Sources

  1. 1.Env Variables and Modes, Vite documentation
  2. 2.Shared Options: define and envPrefix, Vite documentation
  3. 3.Guides: Environment Variables, Next.js documentation
  4. 4.Guides: Data Security, Next.js documentation
  5. 5.Environment variables in Expo, Expo documentation
  6. 6.Environment Variables, TanStack Start documentation
  7. 7.API keys, Supabase Docs
  8. 8.Learn about using and managing API keys for Firebase, Firebase documentation
  9. 9.API keys, Stripe documentation
  10. 10.OpenAI Node API Library (README), OpenAI on GitHub
  11. 11.Anthropic TypeScript API Library (README), Anthropic on GitHub
  12. 12.Google API Keys Weren't Secrets. But then Gemini Changed the Rules., Truffle Security
  13. 13.Build Options: build.sourcemap, Vite documentation
  14. 14.productionBrowserSourceMaps, Next.js documentation
  15. 15.What tech stacks does Lovable use?, Lovable FAQ
  16. 16.Adding Custom Environment Variables, Create React App documentation
  17. 17.Search: find text across all loaded resources, Chrome for Developers

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub