Your API key is in your front-end JavaScript. Here is what to do in the next ten minutes.
Any key in your front-end code is public. Not hidden, not obscured, not hard to find: public. Every visitor's browser downloads your JavaScript, and anyone can open it and read the key. If you have just found one, here is the order to do things in, and why rotating the key comes before touching the code.
Can you hide an API key in front-end JavaScript?
No. There is no way to put a secret in code that runs in the visitor's browser and keep it from the visitor. Minifying, encoding, splitting it across variables and fetching it from a config endpoint all end the same way: the browser has the key, so the person at the keyboard has the key. The only fix is to move the call that needs the key to a server you control, and let the browser call your server instead.
This surprises people because AI builders make it look fine. The app works, the key is "in an environment variable", and nothing warns you. The warning is this post.
Step 1: Rotate the key. Right now, before anything else.
Go to the provider's dashboard, revoke the exposed key, and create a new one. Do this before you touch the code, because deleting the key from the code does not un-leak it: it is already in the browser cache of everyone who visited, in your GitHub history, and possibly in a scraper's database. The key is burned. A new one is the only way back.
| Provider | Where to rotate |
|---|---|
OpenAI (sk-proj-, sk-) | platform.openai.com, API keys: revoke, then create |
Anthropic (sk-ant-) | console.anthropic.com, API Keys |
Stripe (sk_live_, whsec_) | Dashboard, Developers, API keys: roll the key |
Supabase service_role / sb_secret_ | Project settings, API: regenerate (this also logs out every server using it) |
| Resend, SendGrid, Twilio, GitHub tokens | each dashboard's API keys page: delete, then create |
If the key was for a service with a spending limit, set or lower it now too. You can raise it again next week.
Step 2: Check what it was used for
Open the provider's usage or billing page and look at the last few days. A spike you did not cause means someone found it before you did. For OpenAI and Anthropic that usually shows as a burst of requests at odd hours. For Stripe, look at the events log for refunds, payouts or customers you did not create. For a Supabase service_role key, assume the database was readable and decide whether your users need to hear from you.
Most of the time nothing happened yet. Scrapers that collect keys from public JavaScript and GitHub run constantly, though, so "nothing yet" has a short shelf life.
Step 3: Move the call to a server
The key needs to live somewhere the browser cannot see, and the call that uses it needs to happen there. Every builder has a place for this. The pattern is the same everywhere: the browser calls your function, your function reads the key from a secret and calls the provider, and the response comes back.
| Built with | Where the server-side code goes | Where the key goes |
|---|---|---|
| Lovable | a Supabase Edge Function | Supabase secrets (Edge Functions, Secrets) |
| Bolt | a Supabase Edge Function or a Netlify Function | Supabase secrets or Netlify environment variables (not prefixed VITE_) |
| Replit | an Express route in the server folder | the Secrets pane (not a VITE_ variable) |
| v0 / Next.js on Vercel | a Route Handler under app/api/ | Vercel environment variables, without the NEXT_PUBLIC_ prefix |
| Cursor / Windsurf | whatever API layer the project has, or a serverless function | the host's secret store, never a file in the repo |
Security fix: my OpenAI API key is in the front-end code, so anyone visiting the site can copy it.
Move the OpenAI call into a Supabase Edge Function, store the new key as a Supabase secret named OPENAI_API_KEY, and have the front end call that function instead.
Remove the key from every front-end file and from any VITE_ environment variable. Do not change the UI.
For Replit, swap the middle sentence for: "Move the OpenAI call into an Express route on the server, read the key from process.env.OPENAI_API_KEY set in Secrets, and have the front end call that route." For v0: "Move the OpenAI call into a Route Handler at app/api/chat/route.ts, read the key from process.env.OPENAI_API_KEY, and remove the NEXT_PUBLIC_ version."
Step 4: Clean the public variables
Build tools copy every variable with a "public" prefix into the browser bundle. That is the feature working as designed: the prefix is a promise that the value is safe to publish.
| Prefix | Build tool | Used by |
|---|---|---|
VITE_ | Vite | Lovable, Bolt, many Replit Agent apps |
NEXT_PUBLIC_ | Next.js | v0, Vercel templates |
REACT_APP_ | Create React App | older React projects |
EXPO_PUBLIC_ | Expo | mobile apps |
PUBLIC_ | SvelteKit, Astro | Svelte and Astro projects |
Go through your environment variables and delete any with those prefixes whose value is a secret. Keep the ones that are supposed to be public: the Supabase URL and anon key, the Firebase web config, the Stripe publishable key (pk_), analytics IDs. Those are public by design, and the rules behind them (Row Level Security, Firebase rules) are what protect the data.
Step 5: Verify it is gone, everywhere
- The live app. Publish, then open the app, press F12, Network tab, reload, click the biggest
.jsfile and search for the first few characters of the old key and the new one. Neither should appear. - The repo. On GitHub, search the repository for the old key's prefix. It will still be in history; that is why you rotated. Confirm no current file holds the new one.
- The function. Call the feature in the app and confirm it still works. Check the function's logs to see the request land there.
Which keys are safe in the browser and which never are
| Safe in the browser (by design) | Never in the browser |
|---|---|
| Supabase anon key (with RLS on every table) | Supabase service_role key, sb_secret_ keys |
| Firebase web config (with rules not in test mode) | Firebase service account JSON |
Stripe publishable key pk_ | Stripe secret key sk_, webhook secret whsec_ |
| Google Maps key restricted to your domain | OpenAI, Anthropic, Groq, OpenRouter, Gemini API keys |
| Analytics and error-tracking public DSNs | Resend, SendGrid, Twilio, AWS, GitHub tokens, database URLs |
The test is simple: if someone copying this value could spend your money, read your data or send mail as you, it does not go in the browser.
Why AI builders keep doing this
Because it works. The first version of "add an AI chat" that succeeds is the one where the browser calls OpenAI directly, and the SDK's dangerouslyAllowBrowser flag exists precisely to let that happen. The builder is optimizing for the feature working, and the feature works. Nothing in the loop checks who else can see the key. That is also why this comes back: the next prompt that touches the AI feature can move the key right back. Check after every change, or let something check for you.
Questions people ask
Is my OpenAI key safe if it is in an environment variable?
Only if that variable is read on a server. A variable prefixed VITE_, NEXT_PUBLIC_, REACT_APP_ or EXPO_PUBLIC_ is copied into the browser bundle by the build tool, so the key is public. Environment variables read inside an Edge Function, an Express route or a Next.js Route Handler stay on the server.
Can I just restrict the key by domain instead of moving it?
Only for keys whose provider supports referrer restrictions and that are designed to be public, such as a Google Maps key. OpenAI, Anthropic and Stripe secret keys have no such restriction; anyone with the key can use it from anywhere. Those must move to a server.
I removed the key from the code. Is that enough?
No. The old key is still valid and still in browser caches, your GitHub history and any scraper's copy. Rotate it at the provider. Removing it from the code only stops the new key from leaking the same way.
How did someone find my key so fast?
Automated scanners read public GitHub commits and JavaScript bundles around the clock looking for key patterns like sk-proj- and sk_live_. A key in a public repo is often used within minutes. A key in a live app's bundle takes longer to find but is just as exposed.