How It Happens — The Tutorial Problem
Every OpenAI, Anthropic, and Gemini quickstart guide looks roughly like this:
// The tutorial version — looks harmless, ships to production
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "sk-proj-abc123...", // ← your real key, right here in the code
});
const response = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: userMessage }],
});This works perfectly on your machine. The tutorial says it works. So it goes to production.
The moment your app builds, that key is compiled into the JavaScript bundle that every browser downloads. Anyone can open Chrome DevTools → Sources → search for sk- and your key is right there. No hacking required. No special tools. Thirty seconds of clicking.
8,000+ ChatGPT API keys found publicly accessible in a single February 2026 scan · 5,000+ GitHub repos actively exposing keys · OpenAI keys grew 1,212% as a leaked secret category from 2022–2024 · An attacker running automated requests against GPT-4o can generate $500–$5,000 in charges within a single day
The Environment Variable Trap
The most common "fix" developers reach for is moving the key to an environment variable:
// React / Vite — still wrong
const client = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
});
// Next.js — also wrong
const client = new OpenAI({
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
});This feels safer. It is not. Here is why:
- ▸VITE_ and NEXT_PUBLIC_ prefixes exist specifically to embed variables INTO the frontend bundle at build time — they are designed to be public
- ▸The built JavaScript file contains the literal string value of your key — the variable name is gone, the value is baked in
- ▸Anyone who downloads your app's JS bundle can read it with a text editor
- ▸Committing a .env file with VITE_OPENAI_API_KEY to git exposes it in version history forever
Any environment variable prefixed with VITE_, NEXT_PUBLIC_, REACT_APP_, or NUXT_PUBLIC_ is intentionally bundled into client-side code. Never put a secret in any of these.
The Fundamental Principle — Secrets Belong on the Server
The core rule of web security that fixes this entire class of problem:
Anything the browser can read, the attacker can read. Secrets must never reach the browser.
Your frontend code — React, Vue, Svelte, vanilla JS — runs on the user's machine. You have zero control over what happens there. The only place you can keep a secret is on a server you control, where the user's browser never has direct access.
The correct architecture is simple:
Browser (React/Vue/etc.)
│
│ POST /api/chat ← your own endpoint, no key here
│ { message: "..." }
▼
Your Backend (Node.js / Next.js API route / serverless function)
│
│ Has OPENAI_API_KEY in server environment (never sent to browser)
│ Validates the request, applies rate limiting, sanitizes input
▼
OpenAI API
│
▼
Response flows back through your backend to the browserThe browser never sees the API key. It only ever talks to your own server. Your server holds the key and makes the actual LLM call.
Pattern 1 — Next.js API Route (Simplest)
If you're already on Next.js, you have a built-in server layer. Create an API route that the frontend calls instead of OpenAI directly:
// app/api/chat/route.ts ← runs on the server, never sent to browser
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // no NEXT_PUBLIC_ prefix — server only
});
export async function POST(req: NextRequest) {
const { message } = await req.json();
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Invalid message" }, { status: 400 });
}
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
max_tokens: 500,
});
return NextResponse.json({
reply: completion.choices[0].message.content,
});
}Your frontend calls your own endpoint — no key, no OpenAI SDK, no exposure:
// components/ChatBox.tsx ← runs in the browser, no key anywhere
async function sendMessage(message: string) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
const data = await res.json();
return data.reply;
}The OPENAI_API_KEY in your .env.local (no NEXT_PUBLIC_ prefix) never leaves your server. The browser only ever sees the chat response.
Pattern 2 — Express Proxy (Any Frontend Framework)
If you're using React, Vue, Svelte, or any other frontend without a built-in server, add a thin Express backend:
// server/index.ts
import express from "express";
import OpenAI from "openai";
import rateLimit from "express-rate-limit";
import cors from "cors";
const app = express();
app.use(express.json());
// Only allow requests from your frontend domain
app.use(cors({ origin: process.env.FRONTEND_URL }));
// Rate limit — prevent abuse even from your own frontend
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 requests per user per minute
message: { error: "Too many requests" },
});
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // server env only
});
app.post("/api/chat", limiter, async (req, res) => {
const { message } = req.body;
if (!message || typeof message !== "string" || message.length > 2000) {
return res.status(400).json({ error: "Invalid message" });
}
try {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
max_tokens: 500,
});
res.json({ reply: completion.choices[0].message.content });
} catch (err) {
res.status(500).json({ error: "LLM request failed" });
}
});
app.listen(3001);Keep the Express server in the same repo as your frontend under a server/ directory. Deploy the frontend to Vercel/Netlify and the backend to Railway, Render, or a VPS. They talk over HTTPS — the key never touches the browser.
Pattern 3 — Serverless Function (Zero Infrastructure)
For the smallest possible overhead, a single serverless function works perfectly. Here's a Vercel function that deploys with zero config:
// api/chat.ts (Vercel serverless function)
import type { VercelRequest, VercelResponse } from "@vercel/node";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // set in Vercel dashboard, never in code
});
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const { message } = req.body;
if (!message) return res.status(400).json({ error: "Message required" });
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
});
res.json({ reply: completion.choices[0].message.content });
}Set OPENAI_API_KEY in the Vercel dashboard under Project → Settings → Environment Variables. It is encrypted at rest, injected at runtime, and never included in any build artifact or deployment log.
What to Add on Top of the Proxy
A basic proxy hides the key. A production proxy also protects your costs and your users:
- ▸Rate limiting per IP or per authenticated user — prevents a single user from burning your entire quota
- ▸Input length validation — reject messages over a character limit before they hit the LLM
- ▸Authentication — require a session token or JWT on the /api/chat endpoint so only your logged-in users can call it
- ▸Spend monitoring — set a hard monthly limit in the OpenAI dashboard and alert at 80% usage
- ▸Request logging — log every LLM call with user ID, timestamp, token count for cost attribution and abuse detection
// Minimal but complete production proxy endpoint
app.post("/api/chat", authenticate, limiter, async (req, res) => {
const { message } = req.body;
// Validate
if (!message || message.length > 2000) {
return res.status(400).json({ error: "Invalid input" });
}
// Log for cost attribution
console.log({ userId: req.user.id, chars: message.length, ts: Date.now() });
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
max_tokens: 500, // hard cap on response length = cost cap
});
res.json({ reply: completion.choices[0].message.content });
});Check If You're Already Exposed
Before writing a single line of new code, check your existing projects:
# Search your codebase for patterns that suggest a key in frontend code
grep -r "VITE_OPENAI|NEXT_PUBLIC_OPENAI|REACT_APP_OPENAI" ./src
grep -r "sk-proj-|sk-org-" ./src
# Check your git history — keys removed from code still live in commits
git log -p | grep "sk-proj-|OPENAI_API_KEY"
# Scan your built JS bundle after a production build
grep -r "sk-" ./dist ./build ./.next/static 2>/dev/nullIf any of these return results, your key is compromised. Rotate it immediately in the OpenAI dashboard — a key that has been in a public bundle or git history should be treated as stolen even if you haven't seen charges yet.
1. Rotate it immediately in the OpenAI / Anthropic / Google AI dashboard · 2. Check your usage logs for unexpected charges · 3. Set a spend limit on the new key · 4. Implement the proxy pattern before deploying again
Wrapping Up
The rule is simple: secrets belong on the server, never in the browser. Any code that runs in a user's browser is public code — treat it that way.
Calling an LLM from a frontend app is not inherently dangerous. Calling it with a key embedded in the frontend bundle is. The fix is one extra layer — a backend route or serverless function — that takes the API key out of the equation entirely from the browser's perspective.
It adds maybe 30 minutes of setup. It eliminates the risk of a $5,000 surprise bill, a compromised account, and an awkward conversation with your client about why their API quota was drained overnight.