Why your key is public
Next.js has a rule that surprises almost everyone the first time: any environment variable whose name starts with NEXT_PUBLIC_ is inlined into the JavaScript sent to the browser. That's by design. It's how you expose things like a public site URL or a publishable Stripe key. But an OpenAI key is a secret key. It can spend your money, and OpenAI offers no way to restrict it to one website the way Google Maps keys can be referrer-locked.
The leak usually happens one of three ways:
- You named the variable
NEXT_PUBLIC_OPENAI_API_KEYso the browser code could read it. - A tutorial or AI coding tool generated a client component that calls OpenAI directly, sometimes with
dangerouslyAllowBrowser: true. The flag's name is the warning. - The key was pasted straight into a component "just to test it" and shipped.
Don't take our word for it: open your deployed site, view source on any page, and search the JS bundles for sk-. If it appears, anyone on earth can copy it and run up charges on your account.
✕ Before: key in the browser
✓ After: key on your server
Is your key already exposed?
Paste your deployed URL. We'll fetch the live page and its JS bundles, the same files your visitors' browsers download, and search them for the sk- key pattern. The full key is never displayed, even if we find one.
Step 1 · Rename the environment variable
In your .env.local, delete the NEXT_PUBLIC_ prefix. Without the prefix, Next.js only exposes the variable to server code: route handlers, server components and API routes. Browser code that tries to read it gets undefined.
# ✕ Public: inlined into every page's JavaScript
# NEXT_PUBLIC_OPENAI_API_KEY=sk-...
# ✓ Server-only, never sent to the browser
OPENAI_API_KEY=sk-...
Do the same wherever you deploy: in Vercel, Netlify, Railway, or Render, open your project's environment variables and rename it there too, then redeploy. And search your whole project for NEXT_PUBLIC_OPENAI and dangerouslyAllowBrowser. Both should come back empty when you're done.
Renaming stops future leaks. The old key has already been served to browsers, so treat it as public and rotate it in Step 5. That step is not optional.
Step 2 · Add a server route that calls OpenAI
Your browser code still needs answers from OpenAI. It just can't hold the key. So give it one of your own URLs to call. That route runs on your server, reads the key from process.env, and forwards the request. Pick your router:
Create app/api/chat/route.ts. First install the SDK if you haven't: npm install openai.
import OpenAI from "openai";
import { NextResponse } from "next/server";
// This file only ever runs on the server, so the key stays here.
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function POST(req: Request) {
try {
const { message } = await req.json();
if (!message || typeof message !== "string") {
return NextResponse.json(
{ error: "Missing message" },
{ status: 400 }
);
}
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
});
return NextResponse.json({
reply: completion.choices[0].message.content,
});
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "Something went wrong" },
{ status: 500 }
);
}
}
Create pages/api/chat.ts. First install the SDK if you haven't: npm install openai.
import OpenAI from "openai";
import type { NextApiRequest, NextApiResponse } from "next";
// API routes run on the server, so the key never reaches the browser.
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { message } = req.body;
if (!message || typeof message !== "string") {
return res.status(400).json({ error: "Missing message" });
}
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
});
return res.status(200).json({
reply: completion.choices[0].message.content,
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: "Something went wrong" });
}
}
Step 3 · Call your route from the browser
Now replace the direct OpenAI call in your client component with a fetch to your own route. No key, no SDK, nothing secret in the browser:
async function askAI(message: string) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!res.ok) throw new Error("Request failed");
const data = await res.json();
return data.reply as string;
}
Redeploy, open your live site, and view source again. Search for sk-. It should be gone. Or just run the checker at the top of this page against your URL.
Step 4 · Lock the route down
Your new route is a public URL that spends your money, so don't leave it wide open. An unprotected AI endpoint is the next leak after the key itself. At minimum:
- Require sign-in if your app has accounts: check the session inside the route and return
401when there's none. - Rate-limit by IP or user so one script can't loop your endpoint overnight.
- Cap the input: reject oversized messages and fix
max_tokensso nobody can run up a giant completion on your dime. - Set a spend backstop in the OpenAI dashboard: usage limits and alerts under Settings → Limits, so a worst case is a small bill, not a catastrophe.
Step 5 · Rotate the leaked key
The key you shipped is burned. Bots scan new sites for exactly this pattern. Replace it:
- Go to platform.openai.com/api-keys and delete the old key.
- Create a new secret key and put it in
.env.localand your host's env settings asOPENAI_API_KEY, never in code and never with aNEXT_PUBLIC_prefix. - Redeploy and test your app end-to-end.
- Check the old key didn't travel further: search your GitHub repo (including history) for
sk-. If the key was ever committed, deleting the file isn't enough. The rotation you just did is what protects you.
If code runs in the browser, everything it touches is public: keys, tokens, "hidden" prompts, all of it. Secrets live on the server; the browser only ever talks to your routes.
Your 60-second post-fix checklist
- ✓No
NEXT_PUBLIC_variable holds a secret key, locally or on your host. - ✓OpenAI is only called from a route handler, API route, or server component.
- ✓Searching your live site's JS for
sk-finds nothing. - ✓The old key is revoked in the OpenAI dashboard.
- ✓Your route has a login check or rate limit, and a spend alert is set.
FAQ
- Doesn't .env.local hide the key by itself?
- No. .env.local only controls where the value is stored, not where it ends up. With a
NEXT_PUBLIC_prefix, Next.js deliberately copies the value into the browser bundle at build time. Without the prefix, the same file keeps it server-only. - Can I call OpenAI from a server component or getServerSideProps?
- Yes. Both run only on the server, so reading
process.env.OPENAI_API_KEYthere is safe. The rule is about the browser, not about which file type you use. Just never pass the key down into a client component's props. - What about the Edge runtime or middleware?
- Route handlers work on both Node and Edge runtimes, and the key stays server-side either way, as long as it has no
NEXT_PUBLIC_prefix and you don't return it in a response. - I used a different AI provider. Does this apply?
- Completely. The pattern is identical for Anthropic, Stripe secret keys, and Supabase service_role keys: secret on the server, your own route in front of it, browser never touches it. Publishable keys (like Stripe's
pk_live) are the exception. Those are designed to be public.
Fixed the key? Check the rest of your launch.
The exposed OpenAI key is one of several checks ShipReady runs on your live site. It also looks for Stripe and Supabase secrets in your bundle, Supabase tables without row-level security, unprotected API routes, and a missing OG image that makes your launch post look broken. Scans are free, and secret findings stay locked until you prove the site is yours.
Scan my site with ShipReady → Run the free key check