API key exposed in your frontend? How to find it and fix it
Anything shipped to a browser is readable by whoever opens developer tools, so a key in your frontend bundle is already public whether or not anyone has used it yet. Publishable keys from Supabase and Stripe are designed for this and are fine. A secret key is not: rotate it first, then move the call behind a server route so the key never reaches the browser again.
Anything your app sends to a browser can be read by the person using that browser. That includes every JavaScript file, so a key compiled into your bundle is public the moment the site goes live, and bots that scan public sites and repositories for keys do not wait for you to notice.
The good news is that this is one of the easiest problems to check for yourself. The whole check below takes about ten minutes against your live site.
Why does the key end up there in the first place?
Because you asked for something reasonable and the model gave you the shortest thing that works. "Add a chatbot to my app" has a two-line answer that calls the provider straight from the browser with your key in the request, and a longer answer that adds a server route and keeps the key in an environment variable.
Both produce a working chatbot in your preview, and only one of them keeps the key private. Nothing in the prompt told the model which one you wanted, so it picked the short one.
Is every key in the frontend a problem?
No. Some keys are designed to ship to browsers, and treating them as leaks sends people chasing problems they do not have. The test is not where the key sits. It is what the key can do when a stranger holds it.
| Key | In the browser? | What it can do |
|---|---|---|
Supabase publishable (sb_publishable_) or legacy anon |
Fine by design | Only what your RLS policies and grants allow |
Stripe publishable (pk_live_, pk_test_) |
Fine by design | Create tokens and PaymentMethods; cannot charge or read account data |
| Google Maps browser key | Fine once restricted | Restrict it to your websites and to the Maps APIs you use |
OpenAI or Anthropic (sk-, sk-ant-) |
Never | Spends your money on someone else's requests |
Stripe secret (sk_live_) or restricted (rk_live_) |
Never | Secret keys reach every Stripe API; restricted keys reach whatever you granted |
Supabase secret (sb_secret_) or legacy service_role |
Never | Bypasses Row Level Security and reads or changes every row |
| Any database connection string | Never | It is the database |
Sources for that table: Supabase API keys, Stripe API keys and Google Maps Platform security guidance. Stripe now recommends restricted keys over secret keys for server code, and it is explicit that only publishable keys are safe outside your backend.
One caveat on the "fine by design" rows. A Supabase publishable key is only as safe as the rules behind it. If a table has RLS disabled, or has RLS on with a policy that lets anyone read, that public key reads the table. The key is not the problem there, the table is, and the RLS guide shows how to test it.
What does the fix look like in code?
This is the shape that leaks. It runs in the browser, so the key ships with it:
// Runs in the browser. Anyone can read OPENAI_KEY in the bundle.
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: { Authorization: `Bearer ${OPENAI_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-5", input: question }),
});
This is the shape that does not. The browser calls your own route, and only the server knows the key:
// Browser: no key anywhere.
const res = await fetch("/api/ask", { method: "POST", body: JSON.stringify({ question }) });
// Server route (app/api/ask/route.js in Next.js): the key comes from the environment.
export async function POST(req) {
const { question } = await req.json();
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-5", input: question }),
});
return Response.json(await res.json());
}
This version keeps the key secret, but the route is still open: anyone who finds /api/ask can call it and spend your credits through it. Before you ship it, make the route check that the caller is signed in and put a rate limit on it, the same way you would protect any other endpoint that costs money.
Watch the variable name. In Next.js anything prefixed NEXT_PUBLIC_ is compiled into the browser bundle, and in Vite the same goes for VITE_. A secret with either prefix is in your frontend no matter where you call it from.
How do I check my own app?
Do this against your deployed site, not your local dev server, because what matters is what a stranger receives. All you need is a browser.
1. Search every loaded script. Open your live app, open developer tools and reload. In Chrome, Ctrl+Shift+F (Cmd+Option+F on a Mac) searches every resource the page loaded. Search for sk-, sk_live, rk_live, sb_secret, service_role, secret and password.
2. Watch where the browser sends requests. In the Network tab, look for calls that go straight from the browser to a provider such as api.openai.com or api.stripe.com. Whatever authorises that request is visible to the person making it.
3. Paste anything suspicious into a local checker. The secret scanner reads a pasted .env file, config or bundle in your browser and labels each key it recognises. Nothing you paste is uploaded. It tells you what a key looks like; it cannot tell you whether the key still works.
4. Check your git history. A key removed from the current code is still in the commit that added it, and a public repository publishes every commit. Run git log -p | grep -E "sk-|sk_live|sb_secret" in the repository to see them.
I found a secret key. What do I do, in what order?
Order matters more than any single step. Code changes can wait twenty minutes; a live key cannot.
- Make a new key. Create the replacement in the provider's dashboard before you touch anything else.
- Swap it in on the server. Put the new key in a server environment variable and redeploy, so nothing breaks when the old one dies.
- Kill the old key. Revoke it in OpenAI or Anthropic, roll it in Stripe, and on Supabase delete the old secret key (or deactivate the legacy
service_rolekey) once every component uses the new one. Supabase's own order is create, replace, confirm, then retire. - Read the usage log. OpenAI and Anthropic show spend by day, Stripe has an events log, and AWS has CloudTrail. Look for anything you do not recognise while the timeline is fresh.
- Move the call server-side using the pattern above, so the next key does not leak the same way.
- Clean the history last. If the repository is public, removing the key from old commits is worth doing, but rotation already made the key useless. Do this after the urgent steps, not instead of them.
If you are fixing this with your AI builder, give it the destination as well as the problem:
Move my OpenAI key out of the frontend. Create a server-side route that reads the key from an environment variable, calls the provider, and returns only the response. Update the frontend to call my route instead of the provider. The route must only answer signed-in users and must be rate limited. List every file where the key is currently referenced, and do not use a NEXT_PUBLIC_ or VITE_ prefix for it.
How do I stop it happening again?
Any time you ask an AI builder to integrate a paid service, add one sentence saying the key must stay server-side. It costs a few words and it changes which of the two answers you get.
The reason this class of bug survives is that nothing in a normal workflow reads the shipped code and asks whether anything in it should be secret. Your app works, your preview looks right, and the key sits there. Sentrint reads the repository behind the app, flags credentials that should not be there, and gives you a fix prompt for the tool that wrote the code. You can read a full sample report first, and the first scan is free with no card. It is a code scan, not a pentest: it reads what you wrote and does not attack your running site.
Do the Ctrl+Shift+F search today either way. It takes two minutes and it finds the worst version of this problem.