← All guides

Exposed API Keys in NEXT_PUBLIC_ (and VITE_) Env Vars

Updated 2026-10-14

One of the most common mistakes in AI-built apps is a secret API key with a NEXT_PUBLIC_ (or VITE_) prefix. It usually happens for an understandable reason: the code didn't work until the prefix was added. Unfortunately, adding the prefix is exactly what made the key public.

This guide explains what those prefixes do, which keys are fine to expose, how to check whether you've leaked one, and how to fix it properly.

What the prefix actually does

In Next.js, environment variables are only available on the server by default. If a variable's name starts with NEXT_PUBLIC_, Next.js inlines its value into the JavaScript bundle at build time, so browser code can read it.

That means the value is literally written into a .js file that every visitor downloads. Anyone can find it by opening developer tools, viewing the page source, or searching the files under /_next/static/. Minification doesn't hide it.

Vite does the same thing with VITE_: variables with that prefix are exposed on import.meta.env in client code and embedded in the built bundle. Create React App used REACT_APP_, and Expo uses EXPO_PUBLIC_, with the same effect.

There's no such thing as a "hidden" public env var. If the browser can read it, so can everyone.

Which keys are safe to be public

Some services issue keys designed to be shipped to the browser. Their security comes from other controls, not from secrecy.

Generally safe in client code:

Never in client code:

A quick rule: if the provider's docs call it a "secret" or say "keep it on your server," it must never have a public prefix.

How to check if you've leaked one

1. Look at your env files and code.

grep -rnE "(NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_[A-Z0-9_]*(SECRET|SERVICE_ROLE|OPENAI|ANTHROPIC|PRIVATE|TOKEN)" \
  --include="*.ts" --include="*.tsx" --include="*.js" --include="*.env*" .

Names like NEXT_PUBLIC_OPENAI_API_KEY or VITE_SUPABASE_SERVICE_ROLE_KEY are red flags on their own.

2. Look at the built output. Build locally and search the bundle for known key prefixes:

npm run build
grep -rnoE "sk_live_[A-Za-z0-9]+|sk-[A-Za-z0-9_-]{20,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+" .next/static | head

(A Supabase anon key is also a JWT starting with eyJ, so check the role claim of any match. anon is expected; service_role is a leak.)

3. Check git history and committed .env files. A .env file that was committed once is still in history even if it's deleted now:

git log --all --oneline -- .env .env.local .env.production

Tools like gitleaks scan your entire history for hundreds of key formats. PreflightPR runs gitleaks as part of every scan.

How to fix a leaked key

Step 1: Rotate it. Go to the provider's dashboard and revoke or roll the key. This is the step that actually stops misuse. Removing the key from code does nothing for copies already downloaded, cached or scraped from your repo.

Step 2: Remove the public prefix and move the call to the server.

Before (the key ships to every visitor):

"use client";
export async function summarize(text: string) {
  const r = 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-mini", messages: [{ role: "user", content: text }] }),
  });
  return r.json();
}

After (the key stays on the server; the browser calls your own route):

// app/api/summarize/route.ts
import { auth } from "@/auth"; // your auth helper

export async function POST(req: Request) {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const { text } = await req.json();
  if (typeof text !== "string" || text.length > 5000) {
    return new Response("Bad request", { status: 400 });
  }

  const r = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // ✅ server-only
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] }),
  });
  return Response.json(await r.json());
}

Notice the extra lines: an auth check and input limits. Moving a key server-side but leaving the route open to anyone just turns your route into a free proxy for strangers.

For Vite or Lovable apps with no server, put the call in a Supabase Edge Function (or any serverless function) and store the key as a secret there.

Step 3: Guard against regressions. Add .env* (except .env.example) to .gitignore. In Next.js, you can import the server-only package in modules that read secrets, so the build fails if client code imports them:

// lib/openai.ts
import "server-only";
export const openaiKey = process.env.OPENAI_API_KEY!;

Step 4: Set limits at the provider. Spending caps, usage alerts and, where supported, IP or referrer restrictions limit the damage if a key leaks again.

Why this keeps happening

AI coding tools often generate a working demo first. When a server-only variable is undefined in a client component, the quickest "fix" is to add NEXT_PUBLIC_, and the error goes away. The app works, so nobody looks again. In our own deliberately vulnerable sample app, leaked and client-exposed keys were among the 12 critical issues PreflightPR flagged.

Check your repo

Run a free PreflightPR scan on a public repo to see whether secrets, committed .env files or client-exposed keys show up, along with Supabase RLS and Stripe webhook checks. It's an automated best-effort review, not a penetration test, and it's a fast way to catch this before your users do.

Check your repo in about a minute

Paste a public GitHub URL and get a free launch score.

Run a free scan

More guides