securitynextjsstatic-exportpostmortem

Keeping AI Keys Out of the Browser Bundle: A Static-Export Postmortem

How a Next.js static-export app can still leak a paid model key through NEXT_PUBLIC_*, why it happened, and the server-proxy pattern that fixes it for good.

AiVet Engineering··2 min read

Any environment variable prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time. That is the whole point of the prefix — and exactly why a model API key must never wear it. A statically-exported site has no server at request time, which tempts teams into calling the model straight from the browser with a "public" key. There is no such thing as a public paid key.

The failure mode

  • A key named NEXT_PUBLIC_GOOGLE_AI_API_KEY gets inlined into a JS chunk.
  • The chunk is cached on a CDN and trivially greppable by anyone.
  • The key is billable — so exposure is a direct financial liability, not just a leak.

If you can grep an AIza… string out of your deployed /_next/static bundle and it is not the public Firebase web key, you have a live incident. Rotate first, then fix the pattern.

The fix: a thin server proxy

Even a "static" site can keep a small server surface for the few calls that need a secret. Route model calls through a server handler that holds the key in a server-only variable, and send only derived features from the client — never the raw key, never the raw media if you can avoid it.

// app/api/analyze/route.ts (server-only)
export async function POST(req: Request) {
  const apiKey = process.env.GEMINI_API_KEY; // server-only, NOT NEXT_PUBLIC_
  if (!apiKey) return Response.json({ error: 'no key' }, { status: 401 });
  // ...call the model server-side, return only the result
}

For bring-your-own-key features (like imaging), keep the user's key in their browser's localStorage and never transmit it to us. The rule is symmetric: our secrets stay on our server, their secrets stay on their device.

Verification, not vibes

The only proof that a key is gone is a scan of the actually-served bundle plus a validity test of the old key. "We removed the reference" is not evidence until the old chunk 404s and the old key returns API_KEY_INVALID. Treat the deployed artifact as the source of truth.