Guía
Tu API key del LLM probablemente está en el bundle de tu frontend
Por qué las variables VITE_ y NEXT_PUBLIC_ acaban en el navegador, cómo buscar en un build una clave de OpenAI o Anthropic filtrada y la ruta de servidor que lo arregla.
· 7 min de lectura · Por el equipo de vetkit


Le pides a un asistente que añada un chat a tu aplicación React. Instala el SDK de OpenAI, lee la clave desde import.meta.env.VITE_OPENAI_API_KEY y la función funciona al primer intento. El problema es que también le funciona a cualquiera que abra las herramientas de desarrollo del navegador, encuentre la clave en tu JavaScript y empiece a enviar peticiones a cargo de tu cuenta.
Es uno de los errores más habituales que vemos en proyectos desarrollados con ayuda de IA, y es fácil que se escape en la revisión porque nada parece incorrecto. La clave no está escrita en el código. Vive en un archivo .env que git ignora correctamente. El código la lee de una variable de entorno, que es justo lo que todo el mundo dice que hay que hacer.
Cómo ocurre
Las herramientas de build del frontend no tienen variables de entorno en tiempo de ejecución. El navegador ejecuta un bundle estático. Así que, cuando tu código lee import.meta.env.VITE_SOMETHING o process.env.NEXT_PUBLIC_SOMETHING, el bundler sustituye esa expresión por el valor literal durante el build. Después del build ya no hay ninguna variable, solo una cadena dentro de un archivo JavaScript que descarga cada visitante.
Los frameworks lo dejan claro. Next.js dice que las variables públicas se insertan en todo el JavaScript que se envía al navegador. Vite solo expone al código del cliente las variables con el prefijo VITE_ precisamente para que no filtres las demás por accidente. El prefijo es la forma de activarlo expresamente. Cuando un asistente elige VITE_OPENAI_API_KEY porque el nombre sin prefijo no funcionaba en el navegador, lo ha activado por ti.
También hay una variante más discreta. Algunas plantillas de inicio copian la clave en el bundle con la opción define de Vite, por ejemplo 'process.env.GEMINI_API_KEY': JSON.stringify(...) en vite.config.ts. La variable no tiene prefijo público, así que parece segura, pero el efecto es exactamente el mismo.
Los SDK oficiales intentan impedirlo. El SDK de OpenAI para Node se niega a ejecutarse en un navegador a menos que pases dangerouslyAllowBrowser: true, y su README explica por qué: expone tus credenciales secretas en el código del lado del cliente. A veces los asistentes añaden ese flag para que desaparezca un error. Otros clientes, como las llamadas HTTP genéricas o los SDK sin comprobación de navegador, no te avisan en absoluto.
Qué variables son públicas
Si una variable empieza por uno de estos prefijos, da por hecho que su valor lo puede leer cualquiera que cargue tu aplicación:
VITE_en Vite (y en los frameworks basados en él)NEXT_PUBLIC_en Next.jsREACT_APP_en Create React AppEXPO_PUBLIC_en Expo y en las aplicaciones React Native creadas con élVUE_APP_en Vue CLI yGATSBY_en GatsbyPUBLIC_en Astro y en los módulos de entorno públicos de SvelteKit
Estos prefijos sirven para valores que están pensados para ser públicos, como el ID de sitio de una herramienta de analítica o la URL de tu propia API. Nunca sirven para un secreto de proveedor: OpenAI, Anthropic, Google Gemini, Mistral, una clave secreta de Stripe, una clave service role de una base de datos.
Revisa tu build
La prueba más rápida es hacer el build de la aplicación y buscar el prefijo de la clave en el resultado. Las claves de proyecto de OpenAI empiezan por sk-proj-, las de Anthropic por sk-ant- y las API keys de Google por AIza.
npm run build# Vite writes to dist/, Next.js static chunks live in .next/static/grep -rEo "sk-(proj|ant)-[A-Za-z0-9_-]{8}|AIza[0-9A-Za-z_-]{8}" dist .next/static 2>/dev/nullSi eso imprime algo, la clave está en tu bundle. Revisa un sitio desplegado de la misma forma: abre las herramientas de desarrollo, ve a la pestaña Sources o Network y busca en los scripts cargados. Si tú puedes encontrarla en treinta segundos, también puede un script que rastrea sitios buscando exactamente esto.
La solución
Lleva la llamada al proveedor a un servidor que controles y haz que el navegador hable con ese servidor. En Next.js eso es un route handler. En una single-page app con Vite puede ser una función serverless en el mismo host. La clave mantiene un nombre sin prefijo, así que el bundler nunca la inserta.
import OpenAI from 'openai'; // Server-only: no NEXT_PUBLIC_ prefix, never shipped to the browser.const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function POST(request: Request) { const user = await requireSignedInUser(request); // your auth check await rateLimit(user.id); // your limiter: per user, per minute const { message } = await request.json(); if (typeof message !== 'string' || message.length > 4000) { return Response.json({ error: 'Invalid message' }, { status: 400 }); } const response = await client.responses.create({ model: 'gpt-5-mini', input: message, max_output_tokens: 800, }); return Response.json({ reply: response.output_text });}Los tres detalles que rodean la llamada importan tanto como moverla. Sin una comprobación de autenticación, tu ruta se convierte en un proxy gratuito y anónimo hacia el modelo, que es la misma filtración con pasos extra. Sin rate limiting, un solo usuario con sesión iniciada puede disparar la factura. Sin un límite de salida, una sola petición puede generar muchos más tokens de los que necesita tu funcionalidad.
Deja también el nombre del modelo y los límites en el servidor
max_output_tokens, un visitante puede cambiarlos. Decide esos valores en el servidor y acepta del cliente solo el mensaje del usuario.Si la clave ya se publicó
Quitar la variable del código no basta por sí solo. Los builds antiguos siguen en la caché de los navegadores y de las CDN, y puede que la clave ya se haya copiado. Trátala como comprometida:
- Crea una clave nueva en el panel del proveedor y revoca la antigua.
- Pon la nueva clave solo en la configuración de entorno del servidor, con un nombre sin prefijo público.
- Revisa la página de uso del proveedor en busca de tráfico que no reconozcas y establece un límite de gasto.
- Vuelve a desplegar y repite la búsqueda en el build de arriba para confirmar que el nuevo bundle está limpio.
Si la clave llegó a commitearse en git alguna vez, rótala aunque hayas borrado el archivo. El historial de git conserva el valor antiguo.
Detéctalo antes de publicar
Es un error fácil de eliminar con automatización. Un escáner de secretos detecta las claves commiteadas en el repositorio. Sin embargo, no puede ver una clave que vive en un archivo .env ignorado, así que también te conviene un check que lea el código y detecte que una variable pública se está pasando a un cliente de LLM.
Ese segundo patrón es una de las reglas de seguridad para LLM y agentes de los escaneos completos de vetkit: señala un valor procedente de una variable de entorno del lado del cliente (por ejemplo import.meta.env.VITE_* o process.env.NEXT_PUBLIC_*) que llega a un cliente del SDK de un LLM en código del navegador, y explica cómo llevar la llamada al servidor. El Security Quick Scan gratuito de vetkit cubre secretos commiteados, dependencias con vulnerabilidades conocidas y la configuración de seguridad básica. Ninguno de los dos sustituye un vistazo rápido al resultado de tu propio build, que es la evidencia más directa que puedes obtener.