vibecheck
CRITICALSecrets & Exposure

NEXT_PUBLIC secrets: how API keys get exposed in Next.js

Any environment variable prefixed NEXT_PUBLIC_ is bundled into your JavaScript and served to every visitor — no authentication, no server call. Here's how to check if you're affected and how to fix it.

How NEXT_PUBLIC_ works

During a Next.js build, all references to process.env.NEXT_PUBLIC_* are statically replaced with their literal string values. That value is embedded directly in the JavaScript bundle that ships to every browser. There is no runtime lookup — the string is baked in at build time.

This is intentional for values that are meant to be public. The problem is that AI coding tools — and developers under time pressure — often use this prefix for convenience on secrets that should never leave the server.

.env.local (what you write):
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-abc123...

// In your component (what you write):
const key = process.env.NEXT_PUBLIC_OPENAI_API_KEY

// In the browser bundle (what gets shipped):
const key = "sk-proj-abc123..."
⚠
Once built, the secret is in the bundle. Removing it from your .env file does not remove it from deployed bundles or from git history. You must rotate the credential at the provider.

What is and isn't safe to expose

Not all public variables are dangerous. Some keys are designed to be public. The key question is: what can someone do with this value if they have it?

SAFE TO EXPOSE

  • Stripe publishable key (pk_live_...)
  • Supabase anon key with RLS enabled
  • PostHog project token
  • Google Analytics / GTM IDs
  • Public app URLs and feature flags

NEVER EXPOSE

  • OpenAI / Anthropic API key
  • Stripe secret key (sk_live_...)
  • Supabase service_role key
  • Database connection strings
  • JWT signing secrets / encryption keys
  • Webhook secrets

How to check if you're affected

1. Grep your source files

Run this from your project root to list every NEXT_PUBLIC_ variable reference:

grep -rn "NEXT_PUBLIC_" . \
  --include="*.ts" --include="*.tsx" \
  --include="*.js" --include="*.jsx" \
  --include=".env*" \
  --exclude-dir=node_modules

Flag any variable whose value looks like an API key or secret — long random strings, keys starting with known prefixes (sk-, op_, ghp_, etc.).

2. Search the live JS bundle

Open your deployed site. Open DevTools → Sources (Chrome) or Debugger (Firefox). Press Ctrl+Shift+F to search all files. Paste the first 8 characters of your secret.

# Or search from the terminal against your live bundle:
curl -s https://yourapp.com/_next/static/chunks/app/layout.js \
  | grep -o "sk-proj-[a-zA-Z0-9]*"
⚠
If the secret appears in the bundle, it is already public — bots crawl Next.js builds continuously. Assume it has been scraped and rotate it now.

3. Check your .env files for dangerous NEXT_PUBLIC_ values

# List .env values that are NEXT_PUBLIC_ and look like secrets
grep -h "^NEXT_PUBLIC_" .env .env.local .env.production 2>/dev/null \
  | grep -v "=$" \
  | grep -E "(KEY|SECRET|TOKEN|PASSWORD|PASS|PRIVATE|SIGNING)" 

How to fix it

Step 1 — Rotate the exposed credential immediately

Go to the service dashboard and revoke or rotate the key before writing any code. The old value is in your bundle and potentially in git history — no code change makes the old key safe.

Step 2 — Remove the NEXT_PUBLIC_ prefix

# Before (❌ exposed to browser)
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-...

# After (✅ server-only)
OPENAI_API_KEY=sk-proj-...

Step 3 — Move the usage to server-only code

Server-only code in Next.js: Server Components (no 'use client' directive), Route Handlers (app/api/**/route.ts), and Server Actions ('use server').

// ❌ Before — client component, key is in the bundle
'use client'
export function AiChat() {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}` }
  })
}

// ✅ After — server action, key never leaves the server
'use server'
export async function askAi(prompt: string) {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` }
  })
  return response.json()
}
ℹ
To guarantee a module never runs on the client, import server-only at the top of the file. Next.js will throw a build error if that module is imported from a client component.
import 'server-only'

export function callOpenAI(prompt: string) { ... }

Step 4 — Clean git history (if the key was committed)

Rotating the key is more important than cleaning git history (a rotated key is worthless), but you should still remove it to avoid confusing future developers:

# Use git-filter-repo (preferred over BFG):
pip install git-filter-repo
git filter-repo --path .env --invert-paths

# Or remove a specific string across all history:
git filter-repo --replace-text <(echo "sk-proj-OLD_KEY==>REDACTED")
⚠
This rewrites history. Coordinate with your team — everyone needs to re-clone. Force-push to origin once done.

How vibecheck detects this

When you connect your GitHub repo, vibecheck runs two layers of detection:

  1. Gitleaks scans git history and current files for secrets matching 150+ provider-specific patterns (OpenAI, Stripe, Supabase, AWS, GitHub, etc.).
  2. Custom NEXT_PUBLIC_ check specifically looks for environment variable declarations where the key name contains NEXT_PUBLIC_ and the value matches a secret pattern — flagging cases Gitleaks might miss because the value format is ambiguous.

Scan your repo for this and 42 other checks

Connect your GitHub repo and vibecheck runs Gitleaks, Semgrep, njsscan, and 43 custom checks — then Claude explains every finding in plain English. Free for 2 repos.

scan my repo for free →

FAQ

Is it fine if I need the key in the browser for a direct API call?

Almost never. Almost all AI/payment/database APIs have server-side SDKs and you should proxy the call through a Route Handler. Direct browser-to-API calls also expose your key in network requests visible to anyone with DevTools.

My Supabase anon key is NEXT_PUBLIC_ — is that ok?

Yes, if Row Level Security is enabled on all your tables. The anon key is designed to be public — it only grants the permissions your RLS policies allow. Without RLS enabled, the anon key becomes a master key. Check your RLS status first.

Does Vercel / Railway protect NEXT_PUBLIC_ variables?

No. The platform keeps the variable value secret during the build, but it inlines the value into the output bundle. The bundle is then served publicly. The platform cannot protect what Next.js has already embedded in JavaScript.

Can I use NEXT_PUBLIC_ variables in middleware?

Middleware runs on the server (Edge runtime), so technically yes — but if you also reference the variable in a client component, Next.js will bundle it. Use a non-prefixed variable in middleware to be safe.

I removed the variable from .env — am I safe now?

No. The old deployed bundles still contain the value, and anyone who visited the site (or any bot) may have already copied it. Rotate the credential at the provider. The .env change only prevents future builds from including it.

RELATED GUIDES

Supabase RLS DisabledCRITICALAll vulnerability guides →