Saltar al contenido

Fundamentos de seguridad

"No API key found in request" en Supabase, y el arreglo equivocado

"No API key found in request" significa que tu petición llegó a Supabase sin clave. Casi todas las respuestas apuntan a las reglas de tu base de datos.

Vlad Tkachenko11 min de lectura
Una petición llega a una puerta alta con la ranura del pase vacía y una barra cruzada, y la base de datos a la que iba se queda intacta detrás.

En resumen

  • "No API key found in request" significa que tu petición llegó a Supabase sin la cabecera apikey, así que la rechazaron en la puerta antes de preguntarle nada a tu base de datos.
  • El arreglo correcto es tu clave publicable en esa cabecera, que es la clave pensada para ser pública. La librería cliente de Supabase la envía por ti en cada petición.
  • Los dos arreglos a los que la gente recurre en su lugar son la clave secreta y una regla más floja en la tabla. Ambos callan el mensaje, y ambos dejan tus filas legibles para cualquiera que tenga la clave que viaja dentro de tu app.

Ayer tu app funcionaba. Hoy una lista que solía llenarse de filas está vacía, y si abres la consola del navegador hay una respuesta corta de Supabase donde deberían estar los datos:

{
  "message": "No API key found in request",
  "hint": "No `apikey` request header or url param was found."
}

Eso es todo lo que recibes. No dice qué tabla estabas leyendo, qué enviaste ni qué cambiar.

Aquí está la parte que guía tras guía se equivoca: "No API key found in request" habla de una cabecera que falta, y casi todo el consejo que encontrarás habla de las reglas de tu base de datos. En el foro de discusión de Supabase este mensaje tiene un hilo propio, dieciséis personas proponen allí ocho remedios distintos, y tres de ellos dicen que añadas o aflojes una regla en alguna de tus tablas. Todos los que lo probaron cuentan que el mensaje paró. Si una regla fue alguna vez la causa es otra pregunta, y el hilo nunca la resuelve. Lo único seguro después es que más gente puede leer la tabla que antes.

Qué significa "No API key found in request"

Supabase rechazó tu petición en la puerta de la calle, antes de preguntarle nada a tu base de datos.

Cada petición a tu proyecto llega primero a una puerta de entrada que Supabase coloca delante de todo lo demás. Su trabajo en ese momento es estrecho: leer la cabecera apikey, comparar el valor con las claves que tiene tu proyecto y pasar la petición. Si no hay clave que leer se detiene ahí y responde 401, el código de estado para "no sé quién eres". La propia referencia de la puerta de entrada de Supabase dice que una clave ausente o inválida se rechaza así.

Piensa en ella como un portero en la puerta de la calle y no como una cerradura en un archivador. El portero pide un pase a todo el que llega. Las reglas que deciden quién puede abrir cada archivador están dos pisos más arriba, y aquí nadie las consultó, porque nada pasó de la entrada.

Así que, como errores, este es suave. No se leyó nada, no se escribió nada y tus datos están exactamente como estaban. Una petición fue rechazada.

La puerta de entrada lee la clave. Las reglas de tu tabla están una parada más adentro, y una petición rechazada nunca llega a ellas.

¿Por qué llegó mi petición sin clave?

Porque el valor que tu app debía enviar no estaba en la petición que tu navegador hizo de verdad. Cuatro causas cubren casi todos los casos.

La clave nunca llegó a la app compilada. Tu código la lee de una variable de entorno, la compilación que produjo tu sitio en vivo no tenía esa variable, y createClient recibió una cadena vacía. Todo compila, la app carga y cada petición sale sin clave. En un proyecto Vite o Next la variable además tiene que llevar el prefijo que la marca como segura para el navegador, que es una trampa en sí misma.

Estás llamando a la ruta REST a mano. Un fetch escrito contra /rest/v1/tu_tabla envía las cabeceras que escribiste y nada más. La librería cliente de Supabase añade apikey por ti; una petición escrita a mano lleva lo que tú le diste.

Algo en medio la descartó. Una regla de reescritura, un proxy o una puerta de enlace propia se sienta entre tu app y Supabase y reenvía la petición sin la cabecera.

Estás mirando una redirección y no una petición de datos. Si el mensaje aparece después de que alguien inicie sesión o pulse un enlace de confirmación, y la barra de direcciones aún muestra tu URL de Supabase, el ajuste que toca mirar es la Site URL dentro de Auth. La respuesta con más reacciones de todo ese hilo de Supabase es de alguien que había escrito allí la dirección de su sitio sin el https:// delante.

El arreglo, en una línea

Envía tu clave publicable en la cabecera apikey.

import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  'https://tu-proyecto.supabase.co',
  'sb_publishable_…',
)

Si creas el cliente así, la librería pone la clave en la cabecera correcta en cada petición que hace, y tú nunca escribes la cabecera. La referencia de Supabase es concreta sobre cuál es: las claves publicables y secretas viajan en apikey y no en Authorization: Bearer, porque son cadenas opacas y cortas y todo lo que intenta verificarlas como un JWT falla.

Esa clave tiene que estar en tu app. Dice a qué proyecto pertenece una petición, y lo que un visitante con ella alcanza lo deciden las reglas de tus tablas, que es la razón entera de que pueda ser pública. Qué claves de API son seguras en tu frontend recorre el resto de la familia.

El arreglo que lo empeora

La clave secreta entra en la misma cabecera, y en un proyecto antiguo calla el mensaje y sigue funcionando.

Eso es un solo clic mal dado. Tu panel lista ambas claves en la misma página, y el par antiguo se parece: misma forma, misma longitud, una debajo de la otra. Lo grave que sea el clic depende de qué par emite tu proyecto.

Una clave secreta nueva se rechaza en el navegador. Supabase bloquea sb_secret_… mirando la cabecera User-Agent y responde 401. Pegar una en tu frontend no quita el error, que es el mejor resultado disponible aquí.

Una clave service_role antigua no tiene ese bloqueo. Funciona. La lista se llena, la app se comporta igual que la semana pasada, y la clave queda en un archivo que cualquier visitante puede descargar. Allí ignora todas las reglas que has escrito: service_role lleva el atributo BYPASSRLS de Postgres, así que una política nunca se le aplica. Cada fila de cada tabla, legible y escribible por quien abra el archivo.

La nota de Supabase sobre el bloqueo del navegador merece leerse dos veces. El bloqueo responde 401, y un atacante todavía puede usar la clave con otras herramientas. Lo que protege son las peticiones de tu propia app; la misma clave en el mismo archivo responde a una petición enviada desde un script.

El bloqueo del navegador es sobre el navegador. La misma clave en el mismo archivo funciona desde cualquier cosa que no lo sea.

El otro camino equivocado, y por qué es tan fácil

Aflojar una regla en la tabla también calla el mensaje, y cambia quién puede leer tus filas.

Aquí está ese hilo de Supabase entero, agrupado por lo que cada respuesta te pide cambiar:

Qué cambiarCuántas de las ocho respuestasQué cambia eso de verdad
La Site URL dentro de Auth1Dónde aterriza una redirección
Una política o un grant3Quién lee y escribe tus filas
La consulta o la sesión3Tu propio código
La cabecera apikey1Lo que la puerta estaba pidiendo

La última fila es la que responde a la pista del mensaje. Es el comentario de más abajo del hilo y tiene un solo voto.

Ninguna de las otras siete está escrita de mala fe, y eso es lo que lo hace difícil. Un mensaje aparece de verdad en varias situaciones, quien responde hizo funcionar de verdad su propia app, y una política que ya faltaba es algo real que arreglar. Lo que sale mal es el orden: se reescribe una regla para callar un mensaje sobre una cabecera, y la regla es la parte de ese par que nadie revisa después. Tu app funciona en ambos casos, así que nada te dice cuál de los dos hiciste.

Desde fuera, el resultado se ve. Nuestro escáner lee apps en vivo como lo haría un desconocido, y de las 31.056 apps que ha calificado, tres publicaron una clave secreta de Supabase. Eso lo convierte en el hallazgo más raro que tenemos y el más grave.

El impulso de al lado es mucho más común. Detrás de 8.435 de esas apps encontramos un proyecto de Supabase. En 3.680 de ellas la pregunta "¿puede un desconocido leer esta tabla?" llegó a tener una respuesta útil, y 2.096 respondieron que sí: al menos una tabla entregó filas a una petición de fuera sin ninguna cuenta de por medio. En 394 de ellas la tabla abierta estaba nombrada como una tabla de personas.

Parte de eso es deliberado. Un menú publicado, una página de anuncios y un changelog público viven todos en tablas que un desconocido debe leer, y nuestro escáner no puede distinguirlas de una tabla de clientes que se abrió por descuido. Informa de lo que respondió y te deja el juicio a ti, que es la razón de que activar Row Level Security no sea lo mismo que estar protegido.

Lo que un desconocido puede leer, sobre las apps que respondieron. La banda discontinua son las apps que no respondieron nada, que no es lo mismo que una app sin nada abierto.

Si prefieres ver tu propia respuesta antes que deducirla, nuestro escaneo gratuito lee tu sitio en vivo y te dice qué alcanza desde fuera. Tarda unos 20 segundos y no necesita cuenta: escanea tu app.

Cómo saber qué clave pegaste

Lee el principio de la cadena.

sb_publishable_… pertenece a tu app. sb_secret_… nunca. No hay nada que descodificar, porque para qué sirve la clave está escrito en su frente.

Un valor largo que empieza por eyJ es una del par antiguo, y ahí es donde las dos se vuelven difíciles de distinguir. La sección central de una clave así es información legible y no cifrado, y lleva un campo que resuelve la pregunta:

{
  "iss": "supabase",
  "role": "anon",          ← el que importa
  "iat": 1750000000
}

anon es la publicable del par. service_role es la que toca rotar hoy. La documentación de Supabase lo dice más seco de lo que lo diríamos nosotros: si un tutorial o un asistente de IA te dice que copies una clave larga que empieza por eyJ, se escribió para las claves antiguas.

Los dos pares pueden estar activos a la vez, y ese es el detalle con el que la gente tropieza. Crear una clave publicable o secreta deja tus claves anon y service_role exactamente donde estaban, y siguen funcionando hasta que las desactivas en el panel como un paso aparte. Qué cambió con las claves nuevas cubre esa migración, y dónde vive cada valor en tu panel es la página que abrir si nunca has estado allí.

Si la clave secreta ya está en tu app

Rótala antes de tocar nada más, y trabaja en el orden que da Supabase.

Crea una clave secreta nueva en el panel. Sustituye la vieja en todos los sitios donde tu proyecto la usa. Comprueba que cada parte de tu app está en la nueva. Solo entonces retira la vieja, y el último paso cambia según el par que tengas: borrar una clave secreta no se puede deshacer, mientras que desactivar un par anon y service_role antiguo es reversible, así que puedes volver a activarlas si se te escapó algún cliente.

Si rotas primero o reparas la fuga primero depende de si ya hay una copia pública. Rotar una clave service_role de Supabase recorre los dos órdenes y qué leer en tus registros después.

Que siga así cuando el error ya no aparece

El arreglo aguanta hasta el siguiente prompt o cambio que toque tu configuración de Supabase. Basta un prompt para volver a meter una clave o aflojar otra vez una regla, y tu app sigue funcionando en los dos casos, así que solo se nota cuando alguien mira.

Reeve Monitor mira por ti:

  • las nueve comprobaciones cada hora, en hasta tres apps
  • un correo el día en que un nuevo escaneo le pone a tu app una nota más baja que el anterior
  • si la app responde, cada 60 segundos
  • un informe mensual de lo que vio

Una clave que puede escribir cada fila también puede vaciar cada tabla. Reeve Care guarda una copia de tu base de Supabase donde ninguna clave de tu app puede llegar.

  • una copia cifrada cada día, guardada fuera de tu cuenta de Supabase
  • cada copia verificada antes de contar, con un recuento de las filas de cada tabla
  • una restauración con un clic que guarda lo que hay ahora antes de reemplazar nada
  • también tus archivos subidos, en cuanto conectes una credencial de Storage
  • todo lo que hace Monitor

Qué hacer hoy

Qué hacer

  • Lee el mensaje como un rechazo en la puerta. Tus filas no se tocaron y no hay nada que recuperar.
  • Mira en la pestaña de red del navegador la petición que falla y comprueba si hay siquiera una cabecera apikey. Esa única mirada te dice si es un problema de clave o de redirección.
  • Crea tu cliente de Supabase con la clave publicable y deja que la librería envíe la cabecera. sb_publishable_… en un proyecto nuevo, la clave anon en uno antiguo.
  • Si ya hay una clave secreta en tu app, rótala primero. Borrarla del código no cierra la puerta, porque la versión antigua sigue en tu historial y en cualquier copia en caché de tu sitio.
  • Devuelve a su sitio cualquier regla que aflojaras persiguiendo esto, y luego compruébala desde fuera y no desde la vista previa de tu builder.

Empieza por la única tabla de la que vino el error, y lee después las políticas de cada tabla que tocaste esa misma semana. La checklist de seguridad de 10 minutos cubre qué más suele quedarse abierto en una app recién lanzada, y la guía de seguridad de Supabase recorre el resto de lo que un desconocido puede alcanzar.

Preguntas frecuentes

¿Qué significa "No API key found in request"?

Significa que tu petición llegó a Supabase sin la cabecera apikey, así que la puerta de entrada que Supabase pone delante de tu proyecto la rechazó antes de que tu base de datos interviniera. No se leyó nada, no se escribió nada y nada cambió en tus datos. La respuesta trae una pista que dice lo mismo con otras palabras: no apikey request header or url param was found.

¿Qué clave de Supabase va en la cabecera apikey?

Tu clave publicable, que es sb_publishable_ en un proyecto nuevo y la clave anon en uno más antiguo. Supabase la entrega justo para esto y da por hecho que será visible dentro de tu app. La librería cliente la pone en la cabecera en cada petición que hace, así que si creas el cliente con la clave publicable nunca escribes la cabecera tú.

¿Es seguro tener mi clave anon en el navegador?

Sí, y tiene que estar ahí para que tu app pueda hablar con tu proyecto. La clave anon solo dice a qué proyecto pertenece una petición; lo que un visitante con ella alcanza de verdad lo deciden las reglas de Row Level Security de tus tablas. Qué claves de API son seguras en tu frontend recorre toda la familia de claves y enseña a leerlas.

Usé la clave service_role y funcionó. ¿Es un problema?

Sí, y es lo que vale la pena resolver hoy. Una clave service_role lleva el atributo BYPASSRLS de Postgres, así que tus políticas nunca se le aplican. Publicada dentro de tu app queda en un archivo que cualquier visitante puede descargar, y quien lo descargue puede leer y escribir cada fila de cada tabla. Rota la clave primero y luego mueve a un servidor aquello que la necesitaba.

¿Cómo sé qué clave pegué?

Lee el principio de la cadena. sb_publishable_ pertenece a tu app y sb_secret_ nunca. Un valor largo que empieza por eyJ es una del par antiguo, y esas dos se ven idénticas, así que hay que mirar dentro: la sección central se descodifica en datos legibles con un campo role, que dice anon o service_role.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

¿No sabes cómo está tu propia app?

Haz un escaneo gratuito y obtén una nota clara de la A a la F en unos 20 segundos. Sin cuenta y sin tarjeta.

Analizar mi app gratis

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.