← Leakproof

Secret keys in your frontend: the NEXT_PUBLIC_ and VITE_ checklist

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.

1. Know which prefixes are public

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.

2. Find them in your code

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 .

3. Check what actually shipped

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".

4. Move the call to the server

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=….

5. Rotate every key that was ever public

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.

6. Keep .env out of git

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).

7. Cap the damage

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.

Check your repo automatically. Leakproof's free scanner flags secrets behind public prefixes, hard-coded keys, committed .env files and AI endpoints without rate limits. It runs locally:
npx github:beingRealFrank/leakproof
Want 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