Saltar al contenido

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

Una gran llave expuesta dentro de una ventana de navegador de cristal; un robot pule el cristal sin darse cuenta mientras un desarrollador señala alarmado y una mano enguantada intenta coger la llave.Una gran llave expuesta dentro de una ventana de navegador de cristal; un robot pule el cristal sin darse cuenta mientras un desarrollador señala alarmado y una mano enguantada intenta coger la llave.

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.js
  • REACT_APP_ en Create React App
  • EXPO_PUBLIC_ en Expo y en las aplicaciones React Native creadas con él
  • VUE_APP_ en Vue CLI y GATSBY_ en Gatsby
  • PUBLIC_ 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.

bash
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/null

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

app/api/chat/route.ts
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

Si el navegador envía el nombre del modelo o 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:

  1. Crea una clave nueva en el panel del proveedor y revoca la antigua.
  2. Pon la nueva clave solo en la configuración de entorno del servidor, con un nombre sin prefijo público.
  3. Revisa la página de uso del proveedor en busca de tráfico que no reconozcas y establece un límite de gasto.
  4. 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.

Solicita acceso anticipado

Cuéntanos un poco sobre tu equipo y los repositorios que quieres auditar. Incorporamos equipos en pequeños grupos y te responderemos por correo electrónico.

Solo usamos estos datos para responder a tu solicitud. Política de privacidad