5 min read

Leaked API keys in Next.js — NEXT_PUBLIC_ prefix + client-bundle audit

NEXT_PUBLIC_-prefixed env vars ship in the client bundle. Server secrets accidentally prefixed = bundled credentials shipped to every visitor. Here's the detection + fix.

If a server secret is prefixed NEXT_PUBLIC_, it ships in your client JS bundle. Every visitor downloads the secret. This guide walks the audit + fix.

What it is

Next.js distinguishes server-only env vars (no prefix) from public env vars (NEXT_PUBLIC_ prefix). Public-prefix vars are inlined into client JavaScript at build time. Mixing them up = secrets in the bundle.

Vulnerable example

// .env.local
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-XXX  // WRONG — ships to client

// app/components/Chat.tsx
const response = await fetch("https://api.openai.com/v1/chat/completions", {
  headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}` }
});

Fixed example

// .env.local
OPENAI_API_KEY=sk-proj-XXX  // server-only

// app/api/chat/route.ts (server)
export async function POST(req: Request) {
  const { message } = await req.json();
  const r = await fetch("https://api.openai.com/v1/chat/completions", {
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    method: "POST", body: JSON.stringify({ messages: [{ role: "user", content: message }] }),
  });
  return Response.json(await r.json());
}

How Securie catches it

Securie findingcritical
.env.local:12

Leaked API keys in Next.js

Securie's secrets specialist's live_validate step probes every NEXT_PUBLIC_-prefixed var; if the value matches a known secret pattern (sk-proj-, sk-ant-, sk_live_), critical-severity finding before merge.

Suggested fix — ready as a PR
// .env.local
OPENAI_API_KEY=sk-proj-XXX  // server-only

// app/api/chat/route.ts (server)
export async function POST(req: Request) {
  const { message } = await req.json();
  const r = await fetch("https://api.openai.com/v1/chat/completions", {
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    method: "POST", body: JSON.stringify({ messages: [{ role: "user", content: message }] }),
  });
  return Response.json(await r.json());
}
Catch this in my repo →Securie reviews every PR · proves the issue · ships a verified fix PR

Checklist

  • No NEXT_PUBLIC_ prefix on secrets
  • Run grep -rE 'NEXT_PUBLIC_.*KEY' on .env files quarterly
  • Proxy paid-API calls server-side via app/api routes
  • Per-key spend caps as backstop
  • Securie reviews on every PR

FAQ

Can I use NEXT_PUBLIC_ for the publishable Stripe key?

Yes — pk_live_ / pk_test_ are designed to be public. Check the vendor docs for which keys are publishable.

What if I accidentally committed?

See /leak/<vendor> for vendor-specific rotation.

Related guides