5 min readSecurity
API keys in your front end: how to find them and fix them
AI tools often put secret keys where the browser can see them. How to check your app in five minutes, which keys are fine, and how to fix the rest.
If a key is in your front-end code, anyone can read it: open the site, open the JavaScript, search. Some keys are meant to be public, like a Supabase anon key or a Stripe publishable key. Secret keys, like an OpenAI key, a Stripe secret key or a Supabase service role key, must only ever live on a server. Here is how to check your app and move the ones in the wrong place.
Why this keeps happening
AI tools are built to make the feature work now. Calling the OpenAI API straight from a React component works on the first try, so that is often what gets written. The key goes into a VITE_ or NEXT_PUBLIC_ variable, the build copies it into the JavaScript every visitor downloads, and the feature ships.
Nothing breaks, which is the problem. The first sign is usually a bill: someone finds the key, and your account pays for their usage.
.env file hides nothing on its own. In Vite, every variable that starts with VITE_ is built into the browser code. In Next.js the same goes for NEXT_PUBLIC_, and in Expo for EXPO_PUBLIC_. The prefix means "public".Keys that are fine in the browser
- Supabase anon or publishable keyPublic by design. Your data is protected by row-level security, not by hiding the key. My Supabase checklist covers what to check.
- Firebase web configThe
apiKeyin a Firebase config identifies your project; it is not a secret. Protection comes from Security Rules, and App Check if you use it. - Stripe publishable keyKeys that start with
pk_are made for the browser. The secret key, starting withsk_, never is. - Map and analytics keys, restrictedA Google Maps browser key is public, so restrict it in Google Cloud to your own domains and to the APIs you use.
Keys that must never be in the browser
- AI provider keys: OpenAI, Anthropic, Google AI and the rest.
- Stripe secret and restricted keys (
sk_…,rk_…) and webhook signing secrets. - The Supabase service role or secret key.
- Database connection strings with a password in them.
- Email and SMS keys: Resend, SendGrid, Postmark, Twilio.
- Cloud keys and tokens: AWS, Google Cloud service accounts, GitHub.
Check your app in five minutes
- Search the live JavaScriptOpen your site in Chrome, open developer tools, and press Cmd+Option+F (Ctrl+Shift+F on Windows) to search every file the page loaded. Try
sk-,sk_live,service_role,secretandBearer. - Watch the Network tabUse the feature that calls the AI or the payment provider. A request going from the browser straight to
api.openai.com, or to any provider, with anAuthorizationheader means the key is in the browser. - Check the variable namesOpen your
.envfiles and your host’s environment settings. Any secret whose name starts withVITE_,NEXT_PUBLIC_,EXPO_PUBLIC_orREACT_APP_is public. - Search the code and its historyA key committed once stays in git history even after you delete it. The commands below search both. Scanners like gitleaks or TruffleHog do the same more thoroughly.
# In the code as it is now
git grep -nE "sk-[A-Za-z0-9]|sk_live_|service_role"
# Anywhere in the history
git log -p --all | grep -nE "sk-[A-Za-z0-9]|sk_live_|service_role" | headHow to fix it
Move the call to the server. The browser asks your server, your server adds the key and calls the provider, and only the answer goes back. In a Supabase app that is an Edge Function; in Next.js, a Route Handler or Server Action; on Vercel, Netlify or Cloudflare, a function.
// app/api/summarise/route.ts: runs on the server, so the key never reaches the browser.
export async function POST(req: Request) {
// Check who is asking first (your auth), or anyone can spend your credits through this route.
const { text } = await req.json();
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL,
messages: [{ role: "user", content: `Summarise this: ${text}` }],
}),
});
const data = await res.json();
return Response.json({ summary: data.choices?.[0]?.message?.content ?? "" });
}Then, in this order:
- Rotate the key first. Assume it has been copied. Revoke it with the provider and make a new one.
- Put the new key in a server-only variable, with no public prefix, and redeploy.
- Check who is calling your new route, and add a rate limit. Otherwise you have moved the bill from your key to your endpoint.
- Set a spending limit or an alert with the provider, low enough that a mistake costs little.
- Clean the old key out of git history if you like, but rotating is what actually protects you.
If you're not sure what you're looking at
Keys aren't always labelled. A long random string in a config file might be harmless or might be the keys to your database. If you find something and can't tell, treat it as secret until you know. Checking every key, where it goes and what it can do is one of the first things in my audit.
Questions people ask
- Is it safe to put an API key in a .env file?
- On a server, yes: the file stays on the server. In a Vite, Create React App or Expo front end, variables with the public prefix are built into the JavaScript, so a .env file is not a hiding place there.
- Can I hide an API key in a React app?
- No. Anything that reaches the browser can be read, and tricks like splitting or encoding the key only slow people down. Move the call to a server function and keep the key there.
- My API key was exposed. What should I do?
- Rotate it with the provider straight away, then check your usage and billing for calls you didn't make. After that, move the call to the server so the new key never reaches the browser.
- Is the Supabase anon key a secret?
- No. It is designed to be public, and your data is protected by row-level security. The Supabase key to keep secret is the service role key.