Search IOCombats

Search challenges, guides, questions and articles

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?

Beginner8 min interview
Skills tested:
Explaining how NEXT_PUBLIC_ variables are inlined into the client bundleResponding to a leaked key: rotate first, then fixMoving a provider call into a Route HandlerUsing the server-only package to block client imports at build timeChecking a production build for leaked secrets

Advertisement

🧩 Scenario

A teammate built a "Summarise this job description" button. It called the provider from a Client Component and failed because the key was undefined in the browser, so they renamed it to NEXT_PUBLIC_OPENAI_API_KEY and it worked. The change is already in production. The interviewer asks what you do now and how you prevent it next time.

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

  1. Rotate the key in the provider dashboard and update the server environment with the new one.
  2. Check usage on the provider dashboard for unexpected spikes since the deploy.
  3. 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 contain KEY, SECRET or TOKEN without 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

ApproachKey exposureNotes
NEXT_PUBLIC_ key in clientPublic to everyoneNever acceptable for a paid provider key
Route Handler or Server ActionServer onlyStandard fix, add auth and limits
Separate backend serviceServer onlyUseful if many apps share one AI gateway
Provider short-lived client tokenLimited, expiringOnly 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-only guard
  • 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

  1. How would you detect a leaked key in a bundle before it reaches production?
  2. What else besides NEXT_PUBLIC_ can leak a secret to the client in the App Router?
  3. How would you limit damage if a key leaks again?
  4. Your mobile app also needs AI features. Where does its key live?
  5. 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_KEY as a prop to a Client Component
  • Returning err.message from 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-only module 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement