Leaving hardcoded API keys in frontend code is one of the easiest mistakes to make and one of the most expensive to ignore. If an AI code generator or a five minute tutorial pasted a key into your React, Vue, or Next.js app, there is a good chance it now ships inside the JavaScript your users download. The screen never shows it, but the bundle does, and anyone can read a bundle. This post explains which keys are safe to expose, which ones are not, how to find the ones that already leaked, and how to move them somewhere a stranger cannot reach.
Public key or secret key: the confusion behind hardcoded API keys in frontend code
Not every key is a secret. Some are designed to sit in the browser, and treating those as dangerous only wastes your time. The problem is that they look almost identical to the keys that must never leave a server, so the two get mixed up.
Keys that are meant to be public and are fine in client code:
- A Firebase web config object.
- A Supabase anon key. It is public by design, and its safety comes from row level rules, which we cover in Supabase RLS misconfiguration.
- A Stripe publishable key (the one that starts with
pk_). - A Google Maps browser key that you restrict by HTTP referrer.
Keys that are secret and must live only on a server:
- A Stripe secret key (
sk_live_...), which can move real money. - An OpenAI or other model provider key, which spends your money on every request.
- A database service key, such as a Supabase
service_rolekey, which skips every access rule. - A SendGrid or Twilio key, which sends email and SMS billed to you.
- A webhook signing secret, which lets an attacker forge trusted events.
Why “it is in an environment variable” does not mean secret
The most common false comfort is that a key is in an environment variable, so it must be hidden. That is true for a real server process. It is false the moment a build tool inlines the value into the client bundle, and modern frameworks do exactly that on purpose for anything with the right prefix.
In Next.js, any variable named NEXT_PUBLIC_* is written straight into the JavaScript sent to the browser. Vite does the same for VITE_*, and Create React App does it for REACT_APP_*. The prefix is a promise that the value is public. So this, which an AI assistant might generate when you ask it to call a model from the client, ships your key to every visitor:
// .env.local
NEXT_PUBLIC_OPENAI_KEY=sk-acme-live-9f3b2c7a1d
// app/summarize/page.tsx (runs in the browser)
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.NEXT_PUBLIC_OPENAI_KEY}`,
},
body: JSON.stringify({ model: "gpt-4o", messages }),
});
After you build, that key is a plain string in a file the browser downloads. The NEXT_PUBLIC_ prefix did not protect it. It advertised it.
If your JavaScript can read a key, so can everyone who loads your site. An environment variable is not a hiding place when the framework bakes it into the bundle.
How to find keys that already leaked
You do not need special tools to check. Four passes cover most of it, and all of them are read only on your own app.
- View source and search the bundle. Load your site, save the JavaScript files, and search them for
sk_,service_role,secret,api_key, and your provider names. Anything that looks like a credential is one. - Watch the network tab. Open the feature that calls an external service and read the request headers. If an
Authorization: Bearervalue is sitting there in a call made from the browser, it is public. - Grep your git history. A key that was committed once and deleted later is still in history. Search old commits, not just the current tree, because a cloned repo carries every version.
- Check your deployed environment list. Any secret sitting under a
NEXT_PUBLIC_,VITE_, orREACT_APP_name is shipped, full stop.
What an attacker does with each leaked key
The cost depends on the key, but none of the outcomes are minor.
- A model provider key lets anyone run requests on your account until the quota or your card is drained. That is a straight path to denial of wallet, where the bill climbs while nothing looks broken.
- A SendGrid or Twilio key lets an attacker send email and SMS as you, which burns your sending reputation and your balance at the same time.
- A database service key reads and writes every row, skipping the access rules that protect your users. This is an access control failure, the category we track under access control.
- A Stripe secret key can create charges, refunds, and payouts against your account.
The fix: move the secret to a server
The rule is simple. A secret key belongs in a place your users cannot read, which means a server route or a serverless function. The browser calls your endpoint, your endpoint holds the key and calls the provider. Rewritten, the earlier example looks like this:
// .env.local (server only, no NEXT_PUBLIC prefix)
OPENAI_KEY=sk-acme-live-9f3b2c7a1d
// app/api/summarize/route.ts (runs on the server)
export async function POST(req: Request) {
const { messages } = await req.json();
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Authorization": `Bearer ${process.env.OPENAI_KEY}` },
body: JSON.stringify({ model: "gpt-4o", messages }),
});
return Response.json(await res.json());
}
Drop the NEXT_PUBLIC_ prefix so the value stays server side, and the browser only ever talks to your own route. Beyond that, a short checklist keeps the problem from coming back:
- Use publishable and restricted keys on the client. Stripe
pk_, a Google Maps key locked to your referrer, a Supabase anon key backed by row rules. - Restrict every client key by scope. Referrer, allowed origins, and the narrowest permission set the provider offers.
- Rotate any key that ever shipped. If it reached a browser once, treat it as burned and issue a new one. Hiding it later does nothing, since old bundles still exist.
- Add a secret scanner in CI. A pre commit hook or a pipeline step that greps for key patterns catches the next paste before it merges.
This mistake is a good example of a bug that hides in plain sight: the app works perfectly, so nothing prompts a second look. It fits into the wider picture of vibe coded app security, where the generated code runs but the safety step is still yours. In our own early testing, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before, which is the kind of quiet, working flaw an autonomous researcher that tests assumptions is built to catch. More on how we approach that is on our about page.
Frequently asked questions
Are hardcoded API keys in frontend code always a security problem?
No. Some keys are designed to be public, such as a Stripe publishable key, a Firebase web config, a Supabase anon key, or a referrer restricted Google Maps key. The problem is secret keys, like a Stripe sk_live key, a model provider key, a database service_role key, or a Twilio key, which must live only on a server.
Does putting a key in an environment variable keep it secret?
Only if that variable stays on a server. Frameworks inline any variable with a public prefix into the client bundle at build time, so a NEXT_PUBLIC_, VITE_, or REACT_APP_ value ends up as plain text in the JavaScript the browser downloads. The prefix marks a value as public, it does not hide it.
How do I find a leaked key in my own app?
Load your site, save the JavaScript files, and search them for strings like sk_, service_role, secret, and api_key. Then watch the network tab for an Authorization header on calls made from the browser, and grep your git history, since a key committed once stays in old commits even after you delete it.
What can an attacker do with a leaked model provider key?
They can run requests on your account until the quota or your card is drained, which is a form of denial of wallet where the bill climbs while nothing looks broken. Other leaked keys let an attacker send email and SMS on your account, read and write your whole database, or create charges through your payment provider.
How do I move a secret key off the frontend?
Put the secret in a server route or serverless function that holds the key and calls the provider, then have the browser call your own endpoint instead. Drop any public prefix from the variable name so the framework keeps the value server side, and the key never reaches the client bundle.
Do I still need to rotate a key after I move it server side?
Yes. If a key ever shipped to a browser, treat it as compromised and issue a new one, because old bundles and cached files still carry the original value. Rotate the key, restrict client keys by scope and referrer, and add a secret scanner in CI so the next accidental paste is caught before it merges.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
