When an AI tool hits "API key not found" in the browser, the quickest fix it knows is to rename the variable with a public prefix. The error goes away, and the key is now in the JavaScript every visitor downloads. Here's how to find and fix that in about ten minutes.
Anything starting with NEXT_PUBLIC_ (Next.js), VITE_ (Vite, which Lovable and Bolt projects use), EXPO_PUBLIC_ (Expo) or REACT_APP_ (Create React App) is copied into the browser bundle at build time.
Fine to be public: the Supabase URL and anon or sb_publishable_… key, the Firebase web config, Stripe publishable keys (pk_…), analytics IDs. Never public: OpenAI, Anthropic or other AI keys, Stripe secret or restricted keys (sk_…, rk_…), the Supabase service_role or sb_secret_… key, database URLs, webhook signing secrets.
grep -rnE "(NEXT_PUBLIC|VITE|EXPO_PUBLIC|REACT_APP)_[A-Z0-9_]*(SECRET|SERVICE_ROLE|PRIVATE|OPENAI|ANTHROPIC|DATABASE_URL|WEBHOOK)" \ --exclude-dir=node_modules --exclude-dir=.git .
Source can look clean while an old build or a hard-coded fallback still leaks. Build, then search the output:
npm run build
grep -rnoE "sk_live_[A-Za-z0-9]{10,}|sk-(proj-)?[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|sb_secret_[A-Za-z0-9_-]{10,}" .next/static dist build 2>/dev/null | head
Supabase's older keys are JWTs, so the word service_role is encoded inside them. To check one you find in the bundle, decode its middle part: echo '<key>' | cut -d. -f2 | base64 -d 2>/dev/null. It must say "role":"anon".
In Next.js, call the API from a route handler and drop the prefix:
// app/api/chat/route.ts runs on the server; the key never reaches the browser
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no NEXT_PUBLIC_
export async function POST(req: Request) {
// check the user is signed in and rate-limit them here, before spending money
const { messages } = await req.json();
const r = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages });
return Response.json(r.choices[0].message);
}
Vite apps (Lovable, Bolt) have no server of their own: put the call in a Supabase Edge Function, read the key there with Deno.env.get('OPENAI_API_KEY'), and set it with supabase secrets set OPENAI_API_KEY=….
Removing a key from the code doesn't un-leak it: it's in old deployments, browser caches and git history. Create a new key in the provider's dashboard, deploy with it, then delete the old one.
git ls-files | grep -E '(^|/)\.env'
This should print nothing, or only .env.example. If it prints more, add .env* and !.env.example to .gitignore, run git rm --cached on the file, and rotate every value in it (step 5).
Set a monthly spend limit in the OpenAI or Anthropic console and rate-limit your AI endpoints per user, so a leaked key or a looping script costs a known amount, not a surprise bill.
npx github:beingRealFrank/leakproofWant someone to go through all of this, plus your live project, and send fix PRs? See the fixed-price audits.
More checklists: Supabase RLS · Firebase rules