● The #1 key leak in vibe-coded apps

How to hide your OpenAI key in Next.js

If your app calls OpenAI from the browser, your API key is sitting in your site's JavaScript, readable by anyone who opens DevTools. One stolen key can mean a drained account and a surprise bill. The fix takes about 10 minutes: move the call behind a server route so the key never leaves your server.

Time to fix: ~10 min Works with: App Router & Pages Router Skill level: copy-paste

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:

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

Visitor's browser
↓ holds your key, calls ↓
api.openai.com: billed to you, key visible to everyone

✓ After: key on your server

Visitor's browser
↓ calls your own route ↓
/api/chat on your server (key lives here only) → OpenAI

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.

.env.local
# ✕ 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.

Heads up

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.

app/api/chat/route.ts
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.

pages/api/chat.ts
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:

Client component (works with either router)
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:

Step 5 · Rotate the leaked key

The key you shipped is burned. Bots scan new sites for exactly this pattern. Replace it:

  1. Go to platform.openai.com/api-keys and delete the old key.
  2. Create a new secret key and put it in .env.local and your host's env settings as OPENAI_API_KEY, never in code and never with a NEXT_PUBLIC_ prefix.
  3. Redeploy and test your app end-to-end.
  4. 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.
The rule to remember

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

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_KEY there 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

More ShipReady fix guides