Netlify's CI pipeline clones your git repository and runs your build. If a secret ends up in that build with the wrong variable prefix, it ends up in your JavaScript bundle and every visitor can read it. In our scans of apps deployed to Netlify, VITE_-prefixed secrets and REACT_APP_-prefixed secrets are the two most common sources of API key exposure.
The fix is the same regardless of which prefix caused it: rotate the key, move the API call server-side, and store the secret without any public prefix.
TL;DR
Netlify API key exposure has two main causes: (1) secrets with a VITE_ or REACT_APP_ prefix baked into the client bundle at build time, and (2) secrets committed to git and pulled into Netlify's CI build logs. Rotate the exposed key immediately. Then remove the public prefix and move the API call into a Netlify Function, or clean the key from git history and add .env to .gitignore. Deploy previews are a third risk: protect them so every pull request URL doesn't expose your production variables.
Step 1: Find the Exposed Key
Confirm exactly what is out and how it got there before rotating.
Check your JS bundle in the browser. Open your deployed Netlify app, press F12, go to Sources, and search for your key value or its known prefix: sk_proj_ (OpenAI), sk_live_ (Stripe), AKIA (AWS), or eyJhbGci (Supabase JWTs). If it appears inside a .js file on your domain, it is in your client bundle.
Search your codebase for public-prefixed secrets.
# Vite projects (React, Vue, Svelte with Vite)
grep -r "VITE_" . --include="*.env*" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.vue"
# Create React App projects
grep -r "REACT_APP_" . --include="*.env*" --include="*.ts" --include="*.tsx" --include="*.js"
# Next.js on Netlify
grep -r "NEXT_PUBLIC_" . --include="*.env*" --include="*.ts" --include="*.tsx" --include="*.js"
# Hardcoded key values in component files
grep -rE "(sk_proj_|sk_live_|AKIA|Bearer ey)" . --include="*.ts" --include="*.tsx" --include="*.js"
Any variable named VITE_OPENAI_API_KEY, REACT_APP_STRIPE_SECRET, or NEXT_PUBLIC_ANTHROPIC_KEY is in your bundle. It does not matter that the variable is stored safely in Netlify's dashboard: the prefix is an instruction to the build tool to copy the value into the output JavaScript.
Check git history for committed .env files. Netlify's CI clones your repo and runs the build. Any .env file committed at any point was readable during that build.
git log --all --full-history -- .env
git log --all --full-history -- .env.local
git log --all --full-history -- .env.production
If these return commits, the secrets were in your repo history. Anyone who cloned or forked the repo may have a copy.
Check Netlify build logs. In your Netlify dashboard, go to Deploys and click on a recent deploy. Search the build log for your key prefix. Build scripts that console.log environment variables for debugging leave their values in Netlify's logs indefinitely.
CheckYourVibe scans your deployed Netlify app and flags API keys found in the JavaScript bundle, including OpenAI, Stripe, Supabase service-role, and AWS credentials. Run a scan first so you know exactly which keys to rotate.
Step 2: Rotate the Exposed Key Immediately
Rotate before you touch the code. The key is already out.
Generate a new key in the affected service dashboard. Most services let you have multiple active keys during the transition, so production stays live.
Update Netlify environment variables. Go to Site Settings > Build & Deploy > Environment Variables. Find the variable and update the value. Netlify lets you scope variables to specific deploy contexts (Production, Deploy Previews, Branch deploys, Local).
Trigger a new deploy. Netlify doesn't auto-redeploy after an environment variable change. Go to Deploys and click "Trigger deploy." The new build will pick up the updated variable.
Revoke the old key in the service dashboard once the new deploy is live. Do not leave the compromised key active.
Review usage logs. Check the affected service for spikes during the exposure window. OpenAI shows per-key usage under API keys. Stripe logs track all API calls.
Do not wait to confirm abuse before revoking. Automated scanners index GitHub and deployed apps in real time. Exposed OpenAI keys have been drained within minutes of appearing in public repositories.
Step 3: Remove from Git History (if committed)
Scrubbing the current file is not enough. The secret exists in every previous commit.
# Install git-filter-repo
pip install git-filter-repo
# Remove all .env variants from history
git filter-repo --path .env --invert-paths
git filter-repo --path .env.local --invert-paths
git filter-repo --path .env.production --invert-paths
Force-push to all remotes after scrubbing. If the repo was public at any point, treat the key as compromised regardless of whether you can confirm access.
echo ".env" >> .gitignore
echo ".env*.local" >> .gitignore
echo ".env.production" >> .gitignore
git add .gitignore && git commit -m "chore: gitignore .env files"
Step 4: Fix the Root Cause (Public Variable Prefixes)
Different build tools use different prefixes, but they all do the same thing: copy the variable value into the client JavaScript bundle.
| Build tool | Public prefix | Fix |
|---|---|---|
| Vite (React, Vue, Svelte) | VITE_ | Remove prefix, move call to Netlify Function |
| Create React App | REACT_APP_ | Remove prefix, move call to Netlify Function |
| Next.js | NEXT_PUBLIC_ | Remove prefix, move call to API Route or Server Action |
The Vite Case (Most Common on Netlify)
Wrong (key baked into the browser bundle):
// src/api/chat.ts (runs in the browser)
const client = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY, // visible to any visitor
dangerouslyAllowBrowser: true,
});
Right (key stays server-side in a Netlify Function):
// netlify/functions/chat.ts (runs only on the server)
import { Handler } from "@netlify/functions";
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no VITE_ prefix
export const handler: Handler = async (event) => {
const { message } = JSON.parse(event.body || "{}");
const response = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: message }],
});
return {
statusCode: 200,
body: JSON.stringify({ reply: response.choices[0].message.content }),
};
};
Your React component calls /.netlify/functions/chat on your own domain. The Netlify Function calls OpenAI. The key never reaches the browser.
The Create React App Case
Same pattern, different prefix. Remove REACT_APP_ from any secret variable name and move the API call to a Netlify Function. Name the server-side variable without any prefix: OPENAI_API_KEY instead of REACT_APP_OPENAI_API_KEY.
Variable Naming in Netlify
| Wrong | Right |
|---|---|
VITE_OPENAI_API_KEY | OPENAI_API_KEY |
REACT_APP_STRIPE_SECRET | STRIPE_SECRET_KEY |
VITE_ANTHROPIC_API_KEY | ANTHROPIC_API_KEY |
Variables without a public prefix are available only in Netlify Functions. The Netlify dashboard injects them at function runtime, never into the client build.
Step 5: Fix Supabase service_role Misuse
AI tools sometimes generate Supabase clients in frontend code using the service_role key. That key bypasses every Row Level Security policy in your database.
// Wrong: service_role in a React component
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY // full DB access to every visitor
);
// Right: anon key in components, service_role only in Netlify Functions
// Component:
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY // safe, controlled by RLS
);
// netlify/functions/admin-task.ts (server only):
const adminClient = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // no VITE_ prefix
);
Enable RLS on every Supabase table. The anon key is safe to expose because RLS restricts what it can access.
If you rotated your Supabase service_role key, update the Supabase dashboard under Project Settings > API and then update your Netlify environment variable. The old key stops working immediately (including any legitimate server-side admin tasks), so update both before revoking.
Step 6: Protect Deploy Previews
Netlify generates a public URL for every branch push and pull request. Any VITE_ or REACT_APP_ variables in those preview builds are visible to anyone with the URL.
Go to Site Settings > Build & Deploy > Deploy contexts:
- Restrict access to deploy previews: enable password protection or authentication
- Set preview-specific env var values: use test keys or dummy values for the Deploy Previews context so a leaked preview URL doesn't expose production credentials
In Netlify's environment variable panel, you can scope each variable to specific contexts:
OPENAI_API_KEY = sk-prod-real-key (Production only)
OPENAI_API_KEY = sk-test-safe-key (Deploy Previews + Branch deploys)
Step 7: Prevent Future Leaks
Pre-commit Hook with Gitleaks
# Install gitleaks (macOS)
brew install gitleaks
# Test your current repo
gitleaks detect --source . --verbose
# Add pre-commit hook
cat > .git/hooks/pre-commit << 'HOOKEOF'
#!/bin/sh
gitleaks protect --staged --verbose
if [ $? -ne 0 ]; then
echo "Gitleaks found potential secrets. Commit blocked."
exit 1
fi
HOOKEOF
chmod +x .git/hooks/pre-commit
Where Secrets Go on Netlify
| Secret type | Where it lives in Netlify | How to access it |
|---|---|---|
| OpenAI / Anthropic key | Environment Variables (no public prefix) | process.env.OPENAI_API_KEY in a Netlify Function |
| Stripe secret key | Environment Variables (no public prefix) | process.env.STRIPE_SECRET_KEY in a Netlify Function |
| Supabase anon key | Environment Variables with VITE_ prefix | import.meta.env.VITE_SUPABASE_ANON_KEY (public by design) |
| Supabase service_role | Environment Variables (no public prefix) | process.env.SUPABASE_SERVICE_ROLE_KEY in a Netlify Function only |
| Database URL | Environment Variables (no public prefix) | process.env.DATABASE_URL in a Netlify Function only |
| Netlify auth token | Never in site env vars | Revoke at account level under User Settings |
How do I know if my Netlify app has an exposed API key?
Open your deployed app, press F12, go to Sources, and search for your key's prefix (sk_proj_, sk_live_, AKIA, eyJhbGci). If it's in a .js file on your domain, it's in the bundle. Also run grep -r 'VITE_\|REACT_APP_' . in your project and git log --all -- .env to check git history.
What's the difference between VITE_ and REACT_APP_ exposure on Netlify?
Both embed the variable value into your client-side JavaScript at build time, making it visible to any visitor. VITE_ is used by Vite-based projects. REACT_APP_ is used by Create React App. Either prefix on a secret means it's in your bundle. The fix is the same: remove the prefix and move the API call to a Netlify Function.
Can Netlify deploy previews expose my secrets?
Yes. By default, deploy preview URLs are publicly accessible. Any VITE_ or REACT_APP_ variables in those builds are readable by anyone with the URL. Enable deploy preview protection under Site Settings > Build & Deploy > Deploy contexts.
Do I need to rotate if I'm not sure the key was accessed?
Yes. Rotate regardless. Automated scanners index public repos and deployed sites in real time. An OpenAI key exposed in a public repo can be drained within minutes. Rotation takes under 5 minutes.
My NETLIFY_AUTH_TOKEN ended up in a log. What do I do?
Revoke it immediately at app.netlify.com/user/applications. A Netlify auth token gives API access to your account, including reading environment variables from your sites. It's more severe than a single service API key. After revoking, audit your sites' environment variables.
Check your Netlify deployment for API keys in your JavaScript bundle, Supabase service_role misuse, and missing security headers. Free scan, no signup required.