Fundamentos de seguridad
"Failed to send a request to the Edge Function": la causa real
"Failed to send a request to the Edge Function": el navegador no recibió una respuesta legible. Su consola dice por qué y si tu función se ejecutó.

En resumen
- "Failed to send a request to the Edge Function" significa que supabase-js no recibió ninguna respuesta que pudiera leer. O la petición falló por el camino, o el navegador retuvo la respuesta, y tu app no se entera de más a propósito.
- La consola del navegador tiene el motivo. Una línea que menciona la preflight request significa que tu petición nunca salió. Una línea que no la menciona significa que tu función se ejecutó y solo su respuesta quedó retenida.
- La solución es una rama para OPTIONS al principio de la función y las cabeceras CORS en cada respuesta, errores incluidos. Esas cabeceras solo dicen a los navegadores quién puede leer la respuesta; quién puede llamar lo decide una comprobación en tu código.
Tu app tiene un botón que llama a una Supabase Edge Function, ese pequeño trozo de código de servidor que una app hecha con Lovable, Bolt, Cursor o Claude Code usa para el trabajo que no puede hacerse en el navegador, como llamar a OpenAI con una clave. Lo pulsas y, en lugar de un resultado, tu app muestra esto:
Failed to send a request to the Edge Function
La función está desplegada. Llamada desde una terminal, puede que incluso responda perfectamente. Desde tu app falla cada vez, y "failed to send a request to the Edge Function" da a entender que tu petición nunca salió.
Bien puede que sí saliera. Tu función quizá se ejecutó, hizo su trabajo y respondió, y el navegador se negó a entregarle la respuesta a tu app. Cuál de las dos cosas pasó está escrito en un solo sitio, y no es el error que recibió tu app.
¿Qué significa "Failed to send a request to the Edge Function"?
Significa que supabase-js pidió una respuesta y no recibió ninguna que pudiera leer.
La frase es un texto fijo dentro de
supabase-js,
asociado a un error llamado FunctionsFetchError. supabase-js lo lanza cuando
falla la propia petición, antes de que exista ningún código de estado que mirar.
Eso lo separa de su hermano más conocido:
Edge Function returned a non-2xx status code
significa que volvió una respuesta y era un error. Este significa que no volvió
ninguna respuesta legible.
Una petición falla así por un puñado de motivos. El dispositivo está sin conexión, la dirección del proyecto en tu app es incorrecta, o las reglas del navegador para otras direcciones rechazaron la respuesta. Esas reglas se llaman CORS, de Cross-Origin Resource Sharing, e impiden que una página web lea una respuesta de otra dirección salvo que la respuesta lo permita. Cuando la función responde desde una terminal y solo falla en tu app, el sospechoso es CORS, y el navegador te dirá si lo fue.
Tu app vive en una dirección, por ejemplo yourapp.lovable.app, y tu función en
otra, <project-ref>.supabase.co. Piensa en el navegador como el portero de tu
app. El correo que llega de otra dirección solo se le entrega a tu app si lleva
una etiqueta del remitente que diga que tu app puede tenerlo: una cabecera de
respuesta llamada Access-Control-Allow-Origin. Con una carta como la tuya, el
portero primero llama a la puerta del remitente para preguntar si la acepta. Y
cuando rechaza algo, a tu app solo le dice que no funcionó. El motivo lo apunta
en su propia libreta.
Un tercer mensaje es de la misma familia. Relay Error invoking the Edge Function es supabase-js avisando de que el relay de Supabase no pudo llegar a tu
función, así que
comprueba si Supabase tiene un problema
antes de cambiar código.
Por qué tu app no se entera de más
Porque el navegador le oculta el motivo a tu código a propósito.
La página de MDN sobre errores de CORS
lo dice sin rodeos: por motivos de seguridad, el código JavaScript no tiene
acceso a los detalles de qué salió mal en una petición CORS. Tu app sabe que algo
falló y nada más. En Chrome, el error que envuelve supabase-js dice
TypeError: Failed to fetch, un texto que Chromium usa igual para cualquier
fallo de red, así que imprimirlo no aporta nada. Según la misma página de MDN, la
única forma de saber qué pasó es la consola del navegador.
Esa es la libreta del portero. Abre tu app publicada en Chrome o Edge, pulsa F12, elige Console y vuelve a pulsar el botón de tu app. La línea que buscas es roja y empieza así:
Access to fetch at 'https://<project-ref>.supabase.co/functions/v1/<name>' from origin 'https://yourapp.lovable.app' has been blocked by CORS policy:
Todo lo que va después de los dos puntos es el motivo. Firefox y Safari lo redactan de otra forma; las citas de este artículo son las de Chrome, sacadas del código fuente de Chromium.
La llamada a la puerta tiene un nombre técnico, preflight request. Es una
petición con el método OPTIONS que el navegador envía antes que la tuya, y
supabase-js siempre provoca una: envía tu clave en una cabecera Authorization
y tus datos como JSON, y la
guía de CORS de MDN
no incluye ninguna de las dos cosas entre lo que una página puede enviar sin
preguntar antes.
¿Qué mitad falló y llegó a ejecutarse tu función?
Las palabras "preflight request" en la línea de la consola lo deciden. Si están, el navegador se detuvo en la llamada a la puerta y tu petición nunca se envió. Si no están, tu petición pasó, tu función se ejecutó y su respuesta quedó retenida.
| La línea de la consola termina con | Qué pasó | ¿Hizo la función el trabajo? |
|---|---|---|
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. | La llamada recibió respuesta sin etiqueta | No. Solo le llegó la llamada |
Response to preflight request doesn't pass access control check: It does not have HTTP ok status. | La llamada recibió un error como respuesta | No |
Request header field <name> is not allowed by Access-Control-Allow-Headers in preflight response. | Tu app envía una cabecera que la lista de la función no nombra | No |
No 'Access-Control-Allow-Origin' header is present on the requested resource., sin mencionar preflight | La llamada pasó, tu petición llegó y la respuesta no tenía etiqueta | Sí |
| Ninguna línea de CORS | No es CORS: la conexión, la dirección o un tiempo límite de tu código | Mira la pestaña Invocations |
La primera fila es lo que produce una función sin manejador para
OPTIONS. La
página de Supabase sobre el error 500
lo describe desde el lado de la función: la llamada llega a tu código como
cualquier otra petición, y la función registra SyntaxError: Unexpected end of JSON input, porque una llamada a la puerta no trae contenido y el código intentó
leer uno. Su respuesta es un error sin cabeceras, y Chrome informa de la cabecera
que falta antes de mirar el estado. Si esa línea está en la pestaña Logs de la
función, ya lo tienes.
La segunda fila significa que la llamada recibió las cabeceras, y un error con
ellas. Una forma de llegar ahí es una comprobación colocada encima de la rama
de OPTIONS que la llamada no puede superar, como una comprobación de quién
llama. La llamada no lleva ninguna de tus cabeceras, tampoco tu clave, así que
falla cada vez.
La cuarta fila es la que pide ir despacio, porque el trabajo se hizo. Si la función guarda una fila, envía un correo o cobra una tarjeta, eso ya está hecho, y volver a pulsar el botón lo hace otra vez. Lo que falló fue la respuesta, que salió sin etiqueta. Busca primero un error: un fallo cuya respuesta tu código ni llegó a escribir, o una rama de error que devuelve sin las cabeceras. La misma página de Supabase avisa de que entonces el navegador culpa a CORS de un fallo cuya causa real es otra.
Así que lee el propio registro de la función antes de tocar ninguna cabecera. En el panel de Supabase, abre Edge Functions, elige la función y lee sus pestañas Invocations y Logs en el minuto en que pulsaste el botón. Leer el error real recorre las dos, y lo que significa cada código de estado.
La última fila significa que el portero no rechazó nada. La petición falló
por el camino: el dispositivo perdió la conexión, o la dirección del proyecto a
la que llama tu app no existe. Un caso viene de tu propio código. Si pasa un
timeout a supabase.functions.invoke,
supabase-js
cancela la petición cuando se acaba el tiempo e informa de esta misma frase,
aunque la función quizá siga trabajando.
Cómo arreglarlo: responder a la llamada y etiquetar cada respuesta
Dos cambios en la función, y van en el mismo despliegue.
- Una rama para
OPTIONSlo primero de todo, antes de cualquier línea que lea el contenido o compruebe quién llama. Responde a la llamada con las cabeceras CORS y nada más. - Las cabeceras CORS en cada respuesta que devuelve la función, las que salen bien y los errores. Una respuesta sin ellas queda retenida, diga lo que diga.
La guía de CORS de Supabase arma una función más o menos así:
import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors'
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return Response.json({ ok: true }, { headers: corsHeaders })
}
try {
const body = await req.json()
// the function's own work goes here
return Response.json({ done: true }, { headers: corsHeaders })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return Response.json({ error: message }, { status: 400, headers: corsHeaders })
}
})
Las cabeceras vienen del propio supabase-js en vez de una lista pegada en la
función. La guía las importa así desde la versión 2.95.0 de supabase-js, para que
la lista coincida con lo que envían las bibliotecas cliente, y dice que hay que
volver a desplegar tras actualizar. Las funciones más antiguas llevan una copia
escrita a mano de la misma lista, en un archivo llamado _shared/cors.ts. De esa
copia sale la tercera fila de la tabla: una cabecera que tu app envía y que la
lista no nombra.
Si le pides el arreglo a Lovable, Bolt, Cursor o Claude Code, esos dos puntos numerados son el prompt. Nombra los dos. El segundo se olvida con facilidad, y una función que solo tiene el primero pasa de la primera fila de la tabla a la cuarta la próxima vez que falle.
Los ejemplos más recientes de Supabase envuelven toda la función en
withSupabase, que responde a la llamada por ti.
Mover un secreto a una Edge Function
muestra ese envoltorio con una comprobación de quién llama, y esa comprobación es
el tema de la siguiente sección.
Con las cabeceras en cada respuesta, una función que falla deja de mostrar este
mensaje y empieza a mostrar Edge Function returned a non-2xx status code. La
respuesta ya llega a tu app, con el motivo dentro.
¿Decide la cabecera CORS quién puede llamar a tu función?
No. Decide qué páginas web pueden leer la respuesta, y solo los navegadores la miran.
La propia lista de Supabase pone Access-Control-Allow-Origin en *, que
permite cualquier origen, tanto en la copia que exporta supabase-js como en la
antigua escrita a mano. En una función que comprueba el token de quien llama, eso
está bien. En una función que no comprueba nada, permite que cualquier página de
cualquier web llame a tu función desde los navegadores de sus propios visitantes
y lea lo que vuelve.
Limitar la cabecera a tu propio dominio tampoco cierra la función. Un script, una terminal u otro servidor envía la misma petición sin navegador, nunca lee la cabecera y recibe la respuesta completa. Qué abre y qué no un comodín de CORS lo explica en detalle.
El cerrojo de una Edge Function es una comprobación de quién llama, escrita en la función. Importa sobre todo en las funciones que acaban detrás de este error, porque son las que guardan una clave de OpenAI o la clave secreta de tu proyecto, y la clave secreta se salta todas las reglas de row level security que escribiste.
La otra salida de un error de CORS es dejar de usar la función: poner la clave en
una variable VITE_ y llamar a OpenAI directamente desde la página. Funciona al
momento, y pone la clave en el archivo que descarga cada visitante. Es el
primero de los
dos arreglos que te dejan peor.
Ese error se ve desde fuera. Nuestro escaneo gratuito lee el JavaScript de tu app
publicada como lo haría un desconocido y nombra cada clave que encuentra,
incluido si es de las que deben estar ahí. Tarda unos 20 segundos y no necesita
cuenta: escanea tu app.
Que la clave no acabe en la página tras el próximo cambio
Una función que arreglaste hoy puede reescribirse la próxima vez que le pidas una funcionalidad a tu builder, y la forma más rápida de saltarse un error de CORS es la que pone tu clave en la página.
Reeve Monitor vuelve a escanear tu app publicada cada hora:
- las nueve comprobaciones cada hora, en hasta tres apps
- un correo el día en que un nuevo escaneo califica tu app peor que el anterior; una clave secreta en tu JavaScript limita por sí sola la nota a D
- si la app está disponible, cada 60 segundos
- un informe mensual de lo que vio
Una función que guarda la clave secreta de tu proyecto puede cambiar cualquier fila de tu base de datos para quien la llame. Reeve Care guarda una copia de tu base de datos de Supabase donde nada de tu proyecto puede alcanzarla.
- una copia cifrada según un calendario, guardada en el almacenamiento propio de Reeve en la UE, fuera de tu cuenta de Supabase
- cada copia verificada antes de contar, contando las filas de cada tabla
- una restauración en un clic que guarda lo que hay ahora antes de reemplazarlo
- también tus archivos subidos, en cuanto conectes una credencial de Storage
- todo lo que hace Monitor
Qué hacer ahora
Qué hacer
- Abre la consola del navegador en tu app publicada, vuelve a pulsar el botón y lee el final de la línea roja de CORS.
- Si la línea menciona la preflight request, añade al principio de la función una rama para
OPTIONSque devuelva las cabeceras CORS. - Si no la menciona, tu función se ejecutó. Lee sus pestañas Invocations y Logs y comprueba lo que ya hizo antes de que nadie vuelva a pulsar el botón.
- Devuelve las cabeceras CORS en cada respuesta, errores incluidos, e impórtalas desde supabase-js 2.95.0 o posterior para que la lista siga al día.
- Mantén dentro de la función una comprobación de quién llama. La cabecera CORS solo dice a los navegadores quién puede leer la respuesta.
Cuando el error de tu app cambie a Edge Function returned a non-2xx status code, las cabeceras funcionan y la respuesta llega;
ese artículo la lee desde
ahí. La lista de seguridad de 10 minutos cubre lo demás que una app
recién lanzada suele dejar abierto.
Preguntas frecuentes
¿Qué significa "Failed to send a request to the Edge Function"?
Significa que supabase-js pidió una respuesta a tu Supabase Edge Function y no recibió ninguna que pudiera leer. O la petición falló por el camino, por ejemplo porque el dispositivo estaba sin conexión, o el navegador se negó a entregar la respuesta a tu app según sus reglas para peticiones a otras direcciones, llamadas CORS. El navegador oculta el motivo a tu código a propósito y lo escribe en su consola.
¿Por qué mi Edge Function funciona desde una terminal pero no desde mi app?
Porque solo los navegadores aplican CORS. Una terminal, un script u otro servidor envía la petición y lee la respuesta entera. Un navegador comprueba antes que la respuesta diga que tu app puede leerla, y antes de una petición como la que envía supabase-js además pide permiso a la función con una petición OPTIONS aparte. Si falla cualquiera de los dos pasos, tu app recibe este error mientras la misma llamada funciona en cualquier otro sitio.
¿Se ejecutó mi Edge Function si me sale este error?
Lee la línea roja de la consola del navegador. Si menciona la preflight request, el navegador se detuvo en la petición de permiso y tu petición nunca salió. Si no la menciona, la petición pasó, la función hizo su trabajo y solo la respuesta quedó retenida. En ese caso revisa las pestañas Invocations y Logs de la función antes de volver a pulsar el botón, porque una segunda pulsación repite el trabajo.
¿Cómo arreglo un error de CORS en una Supabase Edge Function?
Haz que la función responda primero a la petición OPTIONS, antes de cualquier código que lea el contenido o compruebe a quien llama, y devuelve las cabeceras CORS en cada respuesta, también en las de error. La guía de CORS de Supabase importa esas cabeceras desde supabase-js, versión 2.95.0 o posterior, para que la lista coincida con lo que envía el cliente. Vuelve a desplegar la función después del cambio.
¿Qué es "Relay Error invoking the Edge Function"?
Es un tercer mensaje de supabase-js, que aparece cuando el relay de Supabase no pudo llegar a tu función. No es un rechazo de CORS ni una respuesta de tu propio código, así que comprueba primero si el propio Supabase tiene un problema antes de cambiar nada.
¿Es seguro permitir cualquier origen en las cabeceras CORS de mi Edge Function?
Solo si la función comprueba quién la llama. La cabecera CORS dice a los navegadores qué páginas web pueden leer la respuesta; no impide a nadie llamar a la función. Las cabeceras del propio Supabase permiten cualquier origen, lo cual está bien para una función que comprueba el token de quien llama y deja que cualquier web use una que no lo hace. Limitarla a tu dominio tampoco cierra la función a los scripts.