Is NEXT_PUBLIC_ safe for API keys in Next.js and v0?
By StackSiren · Published
Only for keys that are designed to be public. Next.js copies the value of every NEXT_PUBLIC_ variable your client code uses into the JavaScript sent to the browser at build time, so a secret with that prefix is readable by anyone.
Keep secrets in unprefixed variables and use them only in server code: Route Handlers, Server Actions, Server Components or API routes. The rest of this post shows how to tell the two kinds of key apart, how to check a live app built with v0, Cursor or by hand, and how to fix a leak.
How NEXT_PUBLIC_ works
From the Next.js docs: non-NEXT_PUBLIC_ variables "are only available in the Node.js environment", while prefixing a variable with NEXT_PUBLIC_ tells Next.js to "inline" the value "at build time, into the js bundle that is delivered to the client".
So this client component:
"use client";
export function Chat() {
const key = process.env.NEXT_PUBLIC_OPENAI_API_KEY;
// ...
}
ships with the literal key in a file under /_next/static/chunks/. Two consequences people miss:
- The value is frozen at build time. Changing it in Vercel's dashboard does nothing until you redeploy. That also means rotating a leaked key needs a redeploy, and old deployments still contain the old value.
- Anything you pass from a Server Component to a Client Component as a prop is also sent to the browser. An unprefixed secret is still exposed if you hand it to a
"use client"component.
There's one more route to the bundle: values set in next.config.js under env are always included in the JavaScript bundle, whatever their name. Don't put secrets there.
Safe and unsafe NEXT_PUBLIC_ values
Safe (public by design):
NEXT_PUBLIC_SUPABASE_URLand the Supabase publishable or legacy anon key, if RLS is correctNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY(pk_...)- Firebase web config, public analytics IDs, Sentry DSN
Unsafe (must never have the prefix):
OPENAI_API_KEY,ANTHROPIC_API_KEYand other model keysSTRIPE_SECRET_KEY(sk_...) andSTRIPE_WEBHOOK_SECRETSUPABASE_SERVICE_ROLE_KEYorsb_secret_...DATABASE_URL,RESEND_API_KEY,AUTH_SECRET,CLERK_SECRET_KEY
A useful rule: if the provider calls it "secret", "private" or "server", it never gets NEXT_PUBLIC_.
Step 1: Check your live app
Only test sites you own. In the browser: open DevTools → Sources, find _next/static/chunks, and search across files (Ctrl/Cmd + Shift + F) for sk-, sk_live, service_role, sb_secret and re_.
From a terminal:
SITE=https://yourapp.com
for js in $(curl -s "$SITE/" | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u); do
curl -s "$SITE$js" | grep -oE '(sk-(proj-)?[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]{10,}|sb_secret_[A-Za-z0-9_-]{10,}|re_[A-Za-z0-9_]{20,})' \
| sed "s|^|$js: |"
done
This only checks chunks the homepage loads directly. Pages you reach after signing in load more chunks, so repeat from those pages in DevTools. Our exposed API key checker does the crawl for you, and the env file checker checks for a publicly served .env.
Also search your code:
grep -rn "NEXT_PUBLIC_" --include=*.ts --include=*.tsx --include=*.js . | grep -v node_modules
Review every match against the unsafe list.
Step 2: Rotate anything that leaked
- Revoke or roll the key with the provider.
- Check usage logs and billing for anything unexpected.
- Remove the
NEXT_PUBLIC_version from Vercel (Project → Settings → Environment Variables) and from.env.local. - Add the new key under an unprefixed name. On Vercel, consider marking it Sensitive, which Vercel documents as making the value unreadable in the dashboard after it's created.
- Redeploy. Because values are inlined at build time, nothing changes until you do.
Detailed provider steps are in the exposed API keys guide.
Step 3: Move the call to the server
Route Handler (App Router)
app/api/chat/route.ts:
import "server-only";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { message } = await req.json();
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: String(message).slice(0, 4000) }] }),
});
return NextResponse.json(await r.json(), { status: r.status });
}
The client component now calls your own endpoint:
"use client";
async function send(message: string) {
const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) });
return res.json();
}
Before launch, check the user's session inside the handler and add rate limiting, or anyone can use your endpoint to spend your credits.
Server Action
"use server";
import "server-only";
export async function createCheckout(priceId: string) {
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "https://yourapp.com/thanks",
cancel_url: "https://yourapp.com/pricing",
});
return session.url;
}
Validate priceId against an allowlist so a visitor can't pass an arbitrary price.
Guard rails: server-only
Install the server-only package (npm i server-only) and import it at the top of any module that reads secrets. If a client component ever imports that module, next build fails instead of quietly shipping the secret. The Next.js data security guide recommends this pattern.
// lib/openai.ts
import "server-only";
export const openaiKey = () => process.env.OPENAI_API_KEY!;
v0, Cursor and other generators
v0 produces Next.js code that typically deploys to Vercel, and Cursor writes whatever you ask for. Both can produce a client component that reads a secret if the prompt or the existing code steers that way. The checks above don't depend on the tool: search for NEXT_PUBLIC_, look for "use client" files that touch secrets, and inspect the built JavaScript. When you prompt, say explicitly "call the API from a Route Handler and keep the key in a server-only environment variable".
What about preview deployments?
Vercel builds a preview for each branch or pull request, using whatever environment variables you've assigned to the Preview environment. A secret with NEXT_PUBLIC_ leaks from previews just as it does from production, and preview URLs are often shared in issues and chats. Check the Preview and Development scopes, not only Production.
A quick audit checklist
Run through this list once per project and again before each launch:
- List every variable in Vercel's dashboard for Production, Preview and Development, and in
.env.local. Mark each one public or secret. - Any secret with a
NEXT_PUBLIC_prefix: rotate it, rename it and redeploy. - Search for
"use client"files that import from modules readingprocess.env. Addimport "server-only"to those modules. - Check
next.configfor anenvblock holding anything sensitive. - Confirm
.env*files are in.gitignoreand were never committed (git log --all -- .env.local). - Scan the live build as in Step 1, signed out and signed in.
Related reading
- Are VITE_ environment variables secret?
- I leaked my OpenAI API key. What now?
- How to add security headers on Vercel
FAQ
Can I use NEXT_PUBLIC_ for my Supabase anon key? Yes. It's designed to be public. Your protection is Row Level Security; see the Supabase RLS guide.
I removed NEXT_PUBLIC_ from the name and now the value is undefined in the browser. Why? That's the protection working. Client code can no longer read it. Move the code that needs it into a Route Handler or Server Action.
Does process.env.SECRET in a Server Component leak?
Not by itself. It leaks if you pass the value (or anything derived from it) into a Client Component's props or render it into the HTML.
Is the value hidden if I minify or use a dynamic lookup?
Minification keeps strings intact. Dynamic lookups like process.env[name] are simply not inlined, so they return undefined in the browser rather than hiding anything.
How can I catch regressions? Grep your build output in CI for secret patterns, and run an external scan after each deploy. StackSiren's first scan is free, and paid plans from $29/mo (Solo) include scheduled rescans.
Fix guides for this topic
Platform checklists: v0 · Cursor · Vercel