A teammate shipped NEXT_PUBLIC_OPENAI_API_KEY to make an AI feature work. How do you fix it and keep model API keys out of the browser?
Advertisement
🧩 Scenario
Architecture Walkthrough
Why the key was undefined, and why the rename "worked"
The Next.js docs say non-NEXT_PUBLIC_ environment variables are only available in the Node.js environment. In the browser, Next.js replaces them with an empty value. Adding the NEXT_PUBLIC_ prefix tells Next.js to inline the value into the JavaScript bundle at build time. So the rename did exactly what it promises: it published the key to every visitor. Open the Sources tab in DevTools and you can find it.
Step 1: contain the leak
- Rotate the key in the provider dashboard and update the server environment with the new one.
- Check usage on the provider dashboard for unexpected spikes since the deploy.
- Set a spending limit if your provider supports one, so a future leak has a ceiling.
Rotation comes first. Fixing the code does not un-publish a key that was already downloaded.
Step 2: move the call to the server
The browser should call your own endpoint, and only your endpoint calls the provider. This is the same pattern as any third-party API with a secret: a payment gateway, an SMS service. In Next.js that is a Route Handler. The AI SDK OpenAI provider reads OPENAI_API_KEY from the environment by default, so the key never appears in your code.
Step 3: make the mistake hard to repeat
server-only: import it at the top of the module that creates the model client. The Next.js docs say importing such a module into a Client Component causes a build-time error.- Review rule: no
NEXT_PUBLIC_variable may containKEY,SECRETorTOKENwithout a reason written down. - CI check: after
next build, search the client output for known key prefixes and fail the build if found.
Step 4: do not turn your route into an open proxy
A public route that forwards anything to the model is almost as bad as a leaked key, because anyone can spend your credits through it. Require a session, validate and limit the input, and rate limit per user.
Key Code Explained
// lib/ai.ts
import 'server-only'; // importing this file from a Client Component fails the build
import { createOpenAI } from '@ai-sdk/openai';
export const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY, // no NEXT_PUBLIC_ prefix, never sent to the browser
});
// app/api/summarise/route.ts
import { generateText } from 'ai';
import { openai } from '@/lib/ai';
import { getSession } from '@/lib/auth'; // your own auth helper
export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return new Response('Unauthorized', { status: 401 });
const { jobDescription } = (await req.json()) as { jobDescription?: string };
if (!jobDescription || jobDescription.length > 8000) {
return new Response('Invalid input', { status: 400 });
}
try {
const { text } = await generateText({
model: openai('gpt-5.4'),
instructions: 'Summarise this job description in five bullet points.',
prompt: jobDescription,
maxOutputTokens: 400,
});
return Response.json({ summary: text });
} catch (err) {
console.error('summarise failed', err); // full details stay in server logs
return new Response('Could not summarise right now', { status: 502 });
}
}
// app/jobs/summarise-button.tsx
'use client';
export function SummariseButton({ jobDescription }: { jobDescription: string }) {
async function onClick() {
const res = await fetch('/api/summarise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobDescription }),
});
// The browser only ever sees your route's response, never the key.
console.log(await res.json());
}
return <button onClick={onClick}>Summarise</button>;
}
Tradeoffs
| Approach | Key exposure | Notes |
|---|---|---|
| NEXT_PUBLIC_ key in client | Public to everyone | Never acceptable for a paid provider key |
| Route Handler or Server Action | Server only | Standard fix, add auth and limits |
| Separate backend service | Server only | Useful if many apps share one AI gateway |
| Provider short-lived client token | Limited, expiring | Only where the provider offers it, check its docs |
What Interviewers Actually Check
- Whether you rotate the key before anything else
- Whether you can explain build-time inlining of
NEXT_PUBLIC_variables - Whether you know the
server-onlyguard - Whether your new route has auth and limits, so it is not an open proxy
- Whether you keep provider errors out of the response
Follow-Up Questions
- How would you detect a leaked key in a bundle before it reaches production?
- What else besides
NEXT_PUBLIC_can leak a secret to the client in the App Router? - How would you limit damage if a key leaks again?
- Your mobile app also needs AI features. Where does its key live?
- Why can a public, unauthenticated AI route be as costly as a leaked key?
Common Candidate Mistakes
- Fixing the code and skipping rotation
- Believing a key is safe if it is read inside an event handler or effect
- Passing
process.env.OPENAI_API_KEYas a prop to a Client Component - Returning
err.messagefrom the provider straight to the user - Shipping the new route without auth or rate limiting
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain why the key was undefined before the rename?
- Can you list the incident steps in the right order?
- Can you write a
server-onlymodule for the model client? - Can you write a protected Route Handler for the AI call?
- Can you describe a CI check for leaked secrets?
Summary
NEXT_PUBLIC_ variables are inlined into the browser bundle at build time, so a provider key with that prefix is public. Rotate the key first, then move the model call into a Route Handler that reads a non-public variable, and import server-only in the module that creates the model client so a client import fails the build. Finally, protect the route with auth, input limits and rate limits so it does not become an open proxy for your credits.
Is deleting the variable and redeploying enough?
No. Old bundles may still be cached by browsers or CDNs, and anyone who loaded the page could have copied the key. Rotate the key first, then fix the code.
Can I hide the key by obfuscating it or splitting it into parts?
No. Anything the browser can use, the user can read. Obfuscation only slows people down. The key has to stay on a server you control.
Is a Server Action safer than a Route Handler for this?
Both run on the server, so both keep the key private. The risk is the same in both: returning or logging something that contains the key, or passing it as a prop to a Client Component.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement