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.
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.