How to Fix v0 API Key Exposure (2026)

A v0-generated Next.js app with an exposed OpenAI key can drain your credits in hours. In our scans of Next.js projects deployed on Vercel, the single most common secret exposure pattern is a NEXT_PUBLIC_OPENAI_API_KEY variable that Next.js silently baked into the client JavaScript bundle at build time. The NEXT_PUBLIC_ prefix is Next.js's way of making values available to browser code, and it works exactly as advertised, including for secrets you did not intend to share.

This guide walks you through finding the leak, rotating the key, and fixing the root cause so it does not happen again.

TL;DR

v0 API key exposure has two main causes: (1) secrets with a NEXT_PUBLIC_ prefix baked into the client bundle by Next.js, and (2) v0 generating code that uses your Supabase service_role key in a component or client-side hook. Rotate the exposed key immediately, then either remove the NEXT_PUBLIC_ prefix and move the call to a Route Handler or Server Action, or switch from service_role to the anon key with RLS policies. Do not delay rotation waiting to confirm abuse.

Why v0 Apps Have This Problem

v0 started as a UI component generator, but it has grown into a full Next.js app builder. When you ask it to add AI features, payment processing, or data fetching, it generates code that calls third-party APIs. That generated code often uses environment variables with the wrong prefix.

v0 generates NEXT_PUBLIC_ variables because they work. When v0 adds an OpenAI integration, it may produce process.env.NEXT_PUBLIC_OPENAI_API_KEY because this makes the variable accessible to the React component. The code runs correctly in development. In production, that key is also accessible to every person who visits your site.

v0 targets Vercel deployments. Unlike Bolt.new (which targets Netlify) or Replit (which runs its own servers), v0 apps are built for Vercel and use the Next.js App Router or Pages Router. The fix involves Vercel environment variables and Next.js Route Handlers or Server Actions, not Netlify Functions or Replit Secrets.

Step 1: Find the Exposed Key

Before rotating, confirm exactly what is exposed and how.

1

Check your JS bundle in the browser. Open your deployed v0 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 served from your domain, it is in your client bundle and visible to any visitor.

2

Search your codebase for NEXT_PUBLIC_-prefixed secrets.

# In your v0 project root
grep -r "NEXT_PUBLIC_" . \
  --include="*.ts" --include="*.tsx" --include="*.js" --include="*.env*" \
  --exclude-dir=node_modules --exclude-dir=.next

# Also check .env.local for the prefix
grep "NEXT_PUBLIC_" .env.local 2>/dev/null

Any variable named NEXT_PUBLIC_OPENAI_API_KEY, NEXT_PUBLIC_STRIPE_SECRET_KEY, or NEXT_PUBLIC_ANTHROPIC_API_KEY is almost certainly in your bundle. Rename it (drop the prefix) and move the API call server-side.

3

Check for hardcoded Supabase service_role usage. v0 sometimes generates a Supabase client in a component using the service_role key:

grep -r "service_role\|SERVICE_ROLE" . \
  --include="*.ts" --include="*.tsx" --include="*.js" \
  --exclude-dir=node_modules

The anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY) is designed to be public. The service_role key is not: it bypasses all Row Level Security.

4

Check git history for committed .env files.

git log --all --full-history -- .env.local
git log --all --full-history -- .env.production
git log --all --full-history -- .env

If any of these return commits, the secrets were stored in your repo's history. Anyone who cloned or forked the repo has a copy, even if you deleted the file later.

CheckYourVibe scans your deployed v0 app and flags API keys found in the JavaScript bundle, including OpenAI, Anthropic, Stripe, Supabase service-role, and AWS credentials. It reads what an attacker reads from your public bundle, often before you realize the key is there.

Step 2: Rotate the Exposed Key Immediately

Do not try to fix the code first. The key is already out. Rotate it now.

1

Generate a new key in the affected service's dashboard (OpenAI, Stripe, Supabase, etc.). Most services allow multiple active keys so production stays live during the transition.

2

Update Vercel environment variables. In the Vercel dashboard, go to your Project > Settings > Environment Variables. Find the variable and update its value. Make sure to set it for Production (and optionally Preview and Development). Vercel will use the new value on the next deploy.

3

Redeploy. In Vercel, go to Deployments and trigger a redeploy, or push a commit to your main branch. The new deploy will use the updated secret.

4

Revoke the old key in the service dashboard. Delete or disable the compromised key. A key left active is still usable by anyone who copied it from your bundle.

5

Check usage logs. Review the affected service's dashboard for spikes during the exposure window. OpenAI shows per-key usage in the API dashboard. If you see unexpected requests, note the timeframe and endpoints.

Do not delay revocation waiting to confirm abuse. API abuse can cost thousands of dollars within hours of exposure. Rotate first, investigate second.

Step 3: Remove from Git History (if committed)

If your audit found secrets in git history, scrub them from every commit.

# Install git-filter-repo
pip install git-filter-repo

# Remove .env.local from all history
git filter-repo --path .env.local --invert-paths
git filter-repo --path .env.production --invert-paths
git filter-repo --path .env --invert-paths

After scrubbing, force-push to all remotes and notify any collaborators to re-clone. If the repo was public at any point, treat the key as compromised regardless of whether you can confirm it was accessed.

Add all .env variants to .gitignore:

echo ".env" >> .gitignore
echo ".env.local" >> .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 (NEXT_PUBLIC_ Prefix)

Rotating fixes the immediate risk. Fixing the root cause stops it from happening again on the next deploy.

Why NEXT_PUBLIC_ Variables Are Dangerous

Next.js embeds every variable prefixed with NEXT_PUBLIC_ into the client bundle at build time. The prefix is designed to share configuration values with browser code: public API base URLs, feature flags, analytics IDs. Using it with a secret key is the wrong tool for the job.

Wrong (key ends up in every visitor's browser):

// app/components/ChatWidget.tsx - generated by v0
"use client";

export function ChatWidget() {
  const handleSubmit = async (message: string) => {
    const res = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ model: "gpt-4o", messages: [{ role: "user", content: message }] }),
    });
    // ...
  };
}

Right (API call moved to a Route Handler, key stays server-side):

// app/api/chat/route.ts - server-side only, key never reaches browser
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function POST(req: Request) {
  const { message } = await req.json();
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: message }],
  });
  return Response.json({ reply: response.choices[0].message.content });
}
// app/components/ChatWidget.tsx - client component calls YOUR route, not OpenAI directly
"use client";

export function ChatWidget() {
  const handleSubmit = async (message: string) => {
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    });
    const data = await res.json();
    // ...
  };
}

Your React component calls /api/chat on your own domain. Your Route Handler calls OpenAI. The key never reaches the browser.

Alternative: Server Actions

If you prefer Server Actions over Route Handlers, mark the action with "use server" and the key stays on the server automatically:

// app/actions/chat.ts
"use server";
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function generateReply(message: string) {
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: message }],
  });
  return response.choices[0].message.content;
}

Variable Naming in Vercel

In Vercel's environment variables panel, name your secret without any public prefix:

WrongRight
NEXT_PUBLIC_OPENAI_API_KEYOPENAI_API_KEY
NEXT_PUBLIC_ANTHROPIC_API_KEYANTHROPIC_API_KEY
NEXT_PUBLIC_STRIPE_SECRET_KEYSTRIPE_SECRET_KEY

Then access it in your Route Handler as process.env.OPENAI_API_KEY. Vercel injects it only at runtime, only into server-side code.

Step 5: Fix Supabase service_role Misuse

The Supabase service_role key bypasses every Row Level Security policy in your database. It is designed for server-side admin operations only.

v0 occasionally generates a Supabase client in a component or hook that uses the service_role key directly. Any visitor who opens DevTools has full read and write access to your entire database.

Wrong (service_role in a client component):

// app/components/DataTable.tsx - generated by v0
"use client";
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // never do this
);

Right (anon key client-side, service_role only in Route Handlers):

// app/components/DataTable.tsx - anon key, controlled by RLS
"use client";
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // public by design
);

// app/api/admin/route.ts - service_role only here, server-side
import { createClient } from "@supabase/supabase-js";

const adminClient = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // no NEXT_PUBLIC_ prefix = server-only
);

The anon key is safe to expose because Supabase RLS policies control what it can access. Enable RLS on every table and add policies that match your auth model.

If you rotated your Supabase service_role key, generate a new one in the Supabase dashboard under Project Settings > API and update your Vercel environment variable. The old key stops working for all legitimate admin functions too, so update any server-side code that uses it before revoking.

Step 6: Prevent Future Leaks

Pre-commit Hook with Gitleaks

Gitleaks scans for secrets before each commit:

# Install gitleaks (macOS)
brew install gitleaks

# Test your current repo
gitleaks detect --source . --verbose

# Add a pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
gitleaks protect --staged --verbose
if [ $? -ne 0 ]; then
  echo "Gitleaks found potential secrets. Commit blocked."
  exit 1
fi
EOF
chmod +x .git/hooks/pre-commit

What to Put Where

Secret typeWhere it livesAccessed via
OpenAI / Anthropic keyVercel env vars (no prefix)process.env.OPENAI_API_KEY in a Route Handler
Stripe secret keyVercel env vars (no prefix)process.env.STRIPE_SECRET_KEY in a Route Handler
Supabase anon keyVercel env vars as NEXT_PUBLIC_SUPABASE_ANON_KEYprocess.env.NEXT_PUBLIC_SUPABASE_ANON_KEY (public)
Supabase service_roleVercel env vars (no prefix)process.env.SUPABASE_SERVICE_ROLE_KEY in a Route Handler only
Database URLVercel env vars (no prefix)process.env.DATABASE_URL in server code only

How do I know if my v0 API key is exposed?

Open your deployed v0 app in the browser, press F12, go to Sources, and search for your key value or its prefix (sk_proj_, sk_live_, AKIA, or eyJhbGci for Supabase JWTs). If it appears in a .js file, it is in your client bundle. Also run grep -r 'NEXT_PUBLIC_' . in your codebase to catch prefix-exposed variables before the next deploy.

What does NEXT_PUBLIC_ have to do with API key exposure?

Next.js bakes any variable prefixed with NEXT_PUBLIC_ into your client-side JavaScript at build time. This prefix exists so browser code can read configuration values, but it also means secrets are included. Rename any secret variable by dropping the prefix and move the API call to a Route Handler or Server Action so the key stays on the server.

Does v0 only generate UI components, so there are no API keys to worry about?

v0 has grown beyond pure UI generation. It now writes full Next.js pages with data fetching, server actions, and third-party API integrations. When you prompt it to add AI features, payment handling, or external data sources, it generates code that touches secrets. Always audit for NEXT_PUBLIC_ prefixes before deploying.

Do I need to rotate my key if I am not sure it was accessed?

Yes. Rotate it regardless. Automated scanners index public GitHub repositories within minutes of a push. If the key was in your codebase at any point and the repo is public, assume it was found. Rotation takes under 5 minutes and is always worth it.

Does deleting the Vercel deployment fix the exposure?

No. If the key was in a public GitHub repo or cached by crawlers, deleting the deployment does not remove those copies. Rotate the key first, then clean the root cause in your source code and git history.

Check your v0 deployment for API keys in your JavaScript bundle, Supabase service_role misuse, and security header gaps. Free scan, no signup required.

How-To Guides

How to Fix v0 API Key Exposure (2026)