Cuando le pides a una IA que "conecte tu app con Stripe, OpenAI o Supabase", muchas veces mete la clave directamente en el código del frontend. Funciona a la primera, y por eso nadie lo revisa. El problema es simple y absoluto: todo lo que llega al navegador es público. No hay forma de esconder un texto que el navegador necesita leer. Esta guía explica por qué ocurre, cómo lo encuentra un atacante en un minuto, qué claves son peligrosas y cuáles no, cómo buscarlas tú mismo y qué hacer si ya se ha filtrado una.
Por qué acaba la clave en el navegador
Hay tres caminos habituales. El primero es el prefijo público de las variables de entorno: en Next.js todo lo que empieza por NEXT_PUBLIC_ se incrusta en el JavaScript del cliente, igual que VITE_ en Vite, NUXT_PUBLIC_ en Nuxt o REACT_APP_ en Create React App. La IA ve que "la variable no llega" y le añade el prefijo para que llegue. El segundo es la llamada directa: el asistente escribe fetch('https://api.openai.com/...') en un componente de interfaz con la clave en la cabecera, porque es el ejemplo más corto que conoce. El tercero es un archivo de constantes compartido, importado tanto por el servidor como por el cliente, donde alguien dejó la clave "temporalmente".
En los tres casos el resultado es el mismo: la clave viaja en un archivo .js que cualquiera puede descargar, y se queda en la caché de los CDN, en el historial del navegador de tus usuarios y en los archivos de los rastreadores que escanean internet buscando exactamente eso.
Cómo lo ve un atacante en 60 segundos
- DevTools. Abre tu web, pulsa F12, ve a la pestaña Sources, pulsa Ctrl+Shift+F para buscar en todos los archivos y escribe
sk_live,sk-ant,AKIA,service_roleo-----BEGIN. Si aparece, ya la tiene. - Código fuente. Clic derecho, ver código fuente, y abrir cada archivo
.jsenlazado. Los bundles van minificados pero no cifrados; una búsqueda de texto basta. - Source maps. Si publicas los
.map, además del código ve los nombres originales de variables y los comentarios, incluidos los// TODO: mover al servidor. - Escáneres automáticos. Hay bots que recorren dominios nuevos descargando su JavaScript y probando cada patrón de clave contra el proveedor correspondiente. Una clave de AWS filtrada en un bundle se usa en minutos, no en días.
Qué claves son peligrosas y qué pasa si se filtran
- Stripe
sk_live_. Permite crear cobros, reembolsos y leer datos de clientes. Es dinero real. - OpenAI, Anthropic y similares (
sk-…). Quien la tenga consume a tu cuenta. Las facturas de miles de euros por una clave en un repositorio público son una historia repetida. - AWS
AKIA…y Google Cloud. Según los permisos, acceso a buckets, bases de datos y a crear máquinas para minar a tu costa. - Supabase
service_role. Se salta Row Level Security por completo: lectura y escritura total de tu base de datos. Es la más grave de todas en apps vibe-coded. - Cuentas de servicio de Firebase (JSON). Equivalente al
service_role: control total del proyecto. - Proveedores de email y SMS (Resend, SendGrid, Twilio). Enviar correos y mensajes en tu nombre, es decir, phishing con tu dominio.
- Cadenas de conexión a la base de datos con usuario y contraseña, y secretos de JWT: con el secreto se firman sesiones de cualquier usuario.
- Tokens de acceso guardados en
localStorage. No son claves tuyas sino de tus usuarios, pero cualquier XSS los lee y los roba.
Qué claves sí pueden ir al cliente
No toda clave es un secreto. Algunas están diseñadas para vivir en el navegador, siempre que lo que haya detrás esté bien configurado:
- La clave
anonopublishablede Supabase, protegida por las políticas de RLS de cada tabla. - La clave pública de Stripe (
pk_live_), que solo sirve para iniciar pagos, no para cobrarlos. - La configuración de Firebase (apiKey, projectId…), protegida por las reglas de seguridad y, mejor, por App Check.
- Una clave de Google Maps restringida por dominio desde la consola de Google.
- Identificadores de analítica y de widgets de terceros.
La regla para distinguirlas: una clave puede ir al cliente si el proveedor la llama explícitamente pública y si, aun teniéndola, un desconocido no puede hacer nada que tú no quieras. Si dudas, es secreta.
Cómo buscarlas tú mismo
En el bundle ya construido, busca patrones conocidos. Con Next.js o Vite, tras construir:
grep -rEo "sk_live_[0-9a-zA-Z]+|sk-ant-[0-9a-zA-Z-]+|AKIA[0-9A-Z]{16}|service_role|eyJhbGciOi[0-9a-zA-Z._-]+" .next/static dist 2>/dev/null | head
En el repositorio, antes de que llegue al bundle, herramientas como gitleaks o trufflehog recorren el historial completo, no solo el último commit. Y en el panel de tu hosting, revisa qué variables llevan prefijo público: cada una de ellas se envía al navegador.
Tres sitios donde la gente no mira: los componentes marcados con 'use client' que importan un archivo de configuración compartido; los parámetros de la URL, donde a veces viaja un token que acaba en logs y en el historial; y los repositorios públicos en GitHub, donde la clave puede llevar meses aunque ya no esté en el código actual.
Cómo arreglarlo: la clave se queda en el servidor
El patrón correcto es que el navegador hable con tu servidor y tu servidor hable con el proveedor. Un endpoint propio recibe la petición, comprueba la sesión del usuario, aplica un límite de uso y hace la llamada con la clave leída de una variable de entorno sin prefijo público. Así, en Next.js:
// app/api/summarize/route.ts (solo servidor)
import { getSession } from '@/lib/auth'
export async function POST(req: Request) {
const session = await getSession(req)
if (!session) return new Response('Unauthorized', { status: 401 })
const { text } = await req.json()
if (typeof text !== 'string' || text.length > 5000) {
return new Response('Bad request', { status: 400 })
}
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.OPENAI_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: text }] }),
})
return new Response(r.body, { headers: { 'Content-Type': 'application/json' } })
}
Con Supabase, la misma idea: las operaciones que necesitan service_role van en una Edge Function o en tu backend, nunca en el cliente. Con Firebase, en una Cloud Function. Y en cualquier caso, la variable de entorno se llama OPENAI_API_KEY, no NEXT_PUBLIC_OPENAI_API_KEY.
Para los tokens de sesión, prefiere cookies con HttpOnly a localStorage: una cookie HttpOnly no es legible desde JavaScript, así que un XSS no puede robarla. Si tu librería guarda la sesión en localStorage por defecto, como hace el cliente de Supabase en el navegador, compensa con una CSP estricta o usa su variante para servidor con cookies.
Si ya se ha filtrado: rotar, no borrar
Quitar la clave del código no sirve de nada: ya está en cachés, en el historial de git y, probablemente, en manos de alguien. El orden correcto es:
- Crear una clave nueva en el proveedor y desplegarla en el servidor.
- Revocar la vieja inmediatamente después. En Stripe, OpenAI y AWS se hace desde el panel en un clic.
- Revisar el uso: facturación, logs de API y cobros de los últimos días. Si hubo abuso, abre un ticket con el proveedor; a veces anulan los cargos.
- Poner límites de gasto y alertas en cada proveedor que lo permita, para que la próxima vez duela menos.
- Si la clave daba acceso a datos personales de usuarios, evalúa si debes notificarlo según el RGPD.
Limpiar el historial de git con herramientas como BFG o git filter-repo está bien para higiene, pero no sustituye la rotación.
Cómo lo comprueba Scanaris
Scanaris descarga el JavaScript que sirve tu web, igual que haría un atacante, y busca patrones de claves de los proveedores habituales, tokens JWT, URLs de webhooks y cadenas de conexión. También avisa si tus source maps son accesibles y si hay archivos sensibles al alcance. Cada hallazgo trae un prompt para pegar en tu IA con el arreglo exacto: mover la llamada al servidor y rotar la clave. Si activas la monitorización, te avisa cuando una sesión nueva de vibe coding vuelve a meter una clave en el cliente. Forma parte de la checklist de lanzamiento y de la guía de seguridad para apps hechas con IA.
Preguntas frecuentes
¿La clave anon de Supabase es un secreto?
No. Está pensada para el navegador y la protege el RLS de cada tabla. Lo que sí es secreto es service_role. Si tu RLS está mal, el problema no es la clave anon sino las políticas.
¿Y si la clave está restringida por dominio?
Las restricciones por referer (como en Google Maps) reducen el abuso desde otras webs, pero no desde scripts que falsifican la cabecera. Sirven para claves de bajo riesgo, no para claves que gastan dinero o leen datos.
¿Basta con borrarla del repositorio?
No. Una clave que ha estado en un bundle público o en un commit público está comprometida. Rótala siempre, y después limpia el historial si quieres.