In short
- An environment variable keeps a key out of Git, not out of the browser. A
VITE_,NEXT_PUBLIC_orEXPO_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, Stripesk_andrk_, 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
- Which API keys are safe to expose, and which never
- What a stranger can do with a secret key from your bundle
- How to check your own live app for exposed keys
- How to hide an API key in the frontend: move the call to your server
- The traps people miss after the obvious fix
- After a leak: rotate first, then fix the code
- What Keybrum checks on a live app
- Questions
- Sources
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:
| Framework | Prefix that ships | What happens at build time | What 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 code | VITE_* variables should not contain sensitive information such as API keys |
| TanStack Start (newer Lovable apps) | VITE_ with Vite | Client code can only read variables with the public prefix | Unprefixed variables are for server code only |
| Next.js | NEXT_PUBLIC_ | Each process.env.NEXT_PUBLIC_... reference is replaced by the hard-coded value in the JavaScript sent to the browser | Values 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 bundle | Do 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 build | Do not store any secrets, such as private API keys, in your React app |
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?
| Provider | Fine in the frontend | Never in the frontend | What actually protects you |
|---|---|---|---|
| Supabase | Publishable key sb_publishable_..., legacy anon key | Secret key sb_secret_..., legacy service_role key | Row level security on every table. Secret and service role keys skip it. |
| Firebase | Web config apiKey (starts with AIza), restricted to Firebase APIs | Admin SDK service account JSON, any key allowed to call the Gemini API | Security Rules and App Check, not the secrecy of the key |
| Google Maps | Browser key restricted by website and by API | An unrestricted key in a project where the Gemini API is enabled | API and website restrictions on each key |
| Stripe | Publishable key pk_live_... | Secret key sk_live_..., restricted key rk_live_... | Keeping sk_ and rk_ keys on your server |
| OpenAI, Anthropic, Gemini, other AI APIs | None | Every API key (sk-proj-..., sk-ant-...) | Nothing: whoever holds the key runs models on your bill |
| AWS, databases, email | None | Access keys (AKIA...), database URLs with a password, Resend or SendGrid keys | Nothing once the key is out |
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
Search every loaded file in the browser
Open your live site, then the developer tools. In Chrome, press
Ctrl+Shift+Fon Windows and Linux orCmd+Option+Fon a Mac to open the Search panel, which searches every loaded file. Search forsk-proj-,sk-svcacct-,sk-ant-,sk_live_,rk_live_,sb_secret_,service_role,AKIA,PRIVATE KEYandpostgres://. Visit the pages that load code later, like the dashboard after sign-in, because bundles are often split per page. - 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.comorapi.anthropic.com, or a call toapi.stripe.comcarrying ansk_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
Look for source maps
Open one of your JavaScript files and add
.mapto 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
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.
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/nullservice_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.
// 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
});// 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 });
}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
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
Put the new key on the server only
Store it as a server environment variable or function secret, without a
VITE_,NEXT_PUBLIC_orEXPO_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
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
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
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.Env Variables and Modes, Vite documentation
- 2.Shared Options: define and envPrefix, Vite documentation
- 3.Guides: Environment Variables, Next.js documentation
- 4.Guides: Data Security, Next.js documentation
- 5.Environment variables in Expo, Expo documentation
- 6.Environment Variables, TanStack Start documentation
- 7.API keys, Supabase Docs
- 8.Learn about using and managing API keys for Firebase, Firebase documentation
- 9.API keys, Stripe documentation
- 10.OpenAI Node API Library (README), OpenAI on GitHub
- 11.Anthropic TypeScript API Library (README), Anthropic on GitHub
- 12.Google API Keys Weren't Secrets. But then Gemini Changed the Rules., Truffle Security
- 13.Build Options: build.sourcemap, Vite documentation
- 14.productionBrowserSourceMaps, Next.js documentation
- 15.What tech stacks does Lovable use?, Lovable FAQ
- 16.Adding Custom Environment Variables, Create React App documentation
- 17.Search: find text across all loaded resources, Chrome for Developers