Skip to content

Security basics

"Failed to send a request to the Edge Function" in Supabase

"Failed to send a request to the Edge Function" means the browser got no reply it could read. Its console says why, and whether your function ran.

Vlad Tkachenko11 min read
A barrier arm lowered across the way, with an envelope held on the far side and an empty amber outline where its label should hang.

In short

  • "Failed to send a request to the Edge Function" means supabase-js got no reply it was allowed to read. Either the request failed on the way, or the browser held the answer back, and your app is told nothing more on purpose.
  • The browser console has the reason. A line that mentions the preflight request means your request was never sent. A line that does not means your function ran and its reply was held back.
  • The fix is an OPTIONS branch at the top of the function and CORS headers on every reply, errors included. Those headers only tell browsers who may read the answer; a check in your code decides who may call.

Your app has a button that calls a Supabase Edge Function, the small piece of server code an app built with Lovable, Bolt, Cursor or Claude Code uses for work that cannot happen in the browser, such as calling OpenAI with a key. You press it, and instead of a result your app shows this:

Failed to send a request to the Edge Function

The function is deployed. Called from a terminal, it may even answer perfectly well. From your app it fails every time, and "failed to send a request to the Edge Function" reads as if your request never left.

It may well have. Your function may have run, done its work and answered, and the browser declined to hand the answer to your app. Which of the two happened is written down in one place, and it is not the error your app received.

What does "Failed to send a request to the Edge Function" mean?

It means supabase-js asked for a reply and got none it was allowed to read.

The sentence is fixed text inside supabase-js, attached to an error called FunctionsFetchError. supabase-js raises it when the request itself fails, before there is any status code to look at. That is what separates it from its better-known sibling: Edge Function returned a non-2xx status code means a reply came back and it was an error. This one means no readable reply came back at all.

A request fails that way for a handful of reasons. The device is offline, the project address in your app is wrong, or the browser's cross-origin rules refused the answer. Those rules are called CORS, short for Cross-Origin Resource Sharing, and they stop a web page reading a reply from another address unless the reply says it may. When the function answers from a terminal and fails only in your app, CORS is the one to suspect, and the browser will tell you whether it was.

Your app lives at one address, say yourapp.lovable.app, and your function at another, <project-ref>.supabase.co. Think of the browser as your app's doorman. Post from another address is handed to your app only if it carries a label from the sender saying your app may have it: a reply header called Access-Control-Allow-Origin. For a letter like yours, the doorman first knocks on the sender's door to ask whether they accept it at all. And when he refuses something, he tells your app only that it failed. The reason goes in his own book.

A third message belongs to the same family. Relay Error invoking the Edge Function is supabase-js reporting that Supabase's relay could not reach your function, so check whether Supabase is having a problem before you change any code.

Why your app is told nothing more

Because the browser keeps the reason from your code on purpose.

MDN's page on CORS errors says it plainly: "For security reasons, specifics about what went wrong with a CORS request are not available to JavaScript code." Your app learns that something failed and nothing else. In Chrome the error supabase-js wraps reads TypeError: Failed to fetch, which Chromium uses for every network failure alike, so printing it adds nothing. The same MDN page says the only way to find out what went wrong is the browser's console.

That is the doorman's book. Open your live app in Chrome or Edge, press F12, choose Console, and press the button in your app again. The line you want is red and starts like this:

Access to fetch at 'https://<project-ref>.supabase.co/functions/v1/<name>' from origin 'https://yourapp.lovable.app' has been blocked by CORS policy:

Everything after the colon is the reason. Firefox and Safari word it differently; the quotes in this post are Chrome's, from Chromium's own source.

The knock has a technical name, the preflight request. It is a request with the method OPTIONS that the browser sends before yours, and supabase-js always triggers one: it sends your key in an Authorization header and your data as JSON, and MDN's CORS guide lists neither among the things a page may send without asking first.

Above, the knock is refused and your request never leaves. Below, the knock passes, the function does the work, and only its reply is held back.

Which half failed, and did your function run?

The words "preflight request" in the console line decide it. If they are there, the browser stopped at the knock and your request was never sent. If they are not, your request went through, your function ran, and its answer was held back.

The console line ends withWhat happenedDid the function do the work?
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.The knock was answered without the labelNo. Only the knock reached it
Response to preflight request doesn't pass access control check: It does not have HTTP ok status.The knock was answered with an errorNo
Request header field <name> is not allowed by Access-Control-Allow-Headers in preflight response.Your app sends a header the function's list does not nameNo
No 'Access-Control-Allow-Origin' header is present on the requested resource., with no mention of preflightThe knock passed, your request arrived, and the reply had no labelYes
No CORS line at allNot CORS: the connection, the address, or a timeout your code setCheck the Invocations tab

The first row is what a function with no OPTIONS handler produces. Supabase's page on the 500 error describes it from the function's side: the knock reaches your code like any other request, and the function logs SyntaxError: Unexpected end of JSON input, because a knock carries no body and the code tried to read one. Its reply is an error with no headers on it, and Chrome reports the missing header before it looks at the status. If that line is in the function's Logs tab, you have found it.

The second row means the knock got the headers and an error with them. One way to get there is a check placed above the OPTIONS branch that the knock cannot pass, such as a check on who is calling. The knock carries none of your headers, your key included, so it fails that check every time.

The fourth row is the one to slow down on, because the work happened. If the function saves a row, sends an email or charges a card, that is done, and pressing the button again does it a second time. What failed is the reply, which went out without the label. Look first for an error: a crash, whose answer your code never got as far as writing, or an error branch that returns without the headers. Supabase's same page warns that this makes the browser blame CORS for a failure whose real cause is something else.

So read the function's own record before you touch any headers. In the Supabase dashboard, open Edge Functions, pick the function, and read its Invocations and Logs tabs at the minute you pressed the button. Reading the real error goes through both, and through what each status number means.

The last row means the doorman refused nothing. The request failed on its way: the device lost its connection, or the project address your app calls does not exist. One case comes from your own code. If it passes a timeout to supabase.functions.invoke, supabase-js cancels the request when the time runs out and reports this same sentence, even though the function may still be working.

How to fix it: answer the knock, and label every reply

Two changes in the function, and they belong in the same deploy.

  1. An OPTIONS branch as the very first thing, before any line that reads the body or checks who is calling. It answers the knock with the CORS headers and nothing else.
  2. The CORS headers on every reply the function returns, the successful ones and the errors. A reply without them is held back, whatever it says.

Supabase's CORS guide shapes a function roughly like this:

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 })
  }
})

The headers come from supabase-js itself rather than from a list pasted into the function. The guide imports them this way from supabase-js version 2.95.0, so the list matches what the client libraries send, and it says to redeploy after upgrading. Older functions carry a hand-written copy of the same list in a file called _shared/cors.ts. That copy is where the third row of the table comes from: a header your app sends that the list does not name.

If you are asking Lovable, Bolt, Cursor or Claude Code to make the fix, those two numbered points are the prompt. Name both. The second is easy to leave out, and a function that has only the first moves from the first row of the table to the fourth the next time it crashes.

Supabase's newer examples wrap the whole function in withSupabase, which answers the knock for you. Moving a secret into an Edge Function shows that wrapper with a check on who is calling, and that check is the subject of the next section.

Once the headers are on every reply, a function that crashes stops showing this message and starts showing Edge Function returned a non-2xx status code. The reply now reaches your app, with the reason inside it.

The browser looks for the label and never at what the reply says. An error that carries the headers reaches your app; one without them is held back and reported as a failed request.

Does the CORS header decide who can call your function?

No. It decides which web pages may read the answer, and only browsers ever look at it.

Supabase's own list sets Access-Control-Allow-Origin to *, which allows every origin, both in the copy supabase-js exports and in the older hand-written one. On a function that checks the caller's token, that is fine. On a function that checks nothing, it lets any page on any website call your function from its own visitors' browsers and read what comes back.

Narrowing the header to your own domain does not close the function either. A script, a terminal or another server sends the same request without a browser, never reads the header, and gets the full answer. What a CORS wildcard does and does not open goes through that in detail.

The lock on an Edge Function is a check on who is calling, written into the function. It matters most on the functions that end up behind this error, because they are the ones holding an OpenAI key or your project's secret key, and the secret key gets past every row level security rule you wrote.

The other way out of a CORS error is to stop using the function: put the key in a VITE_ variable and call OpenAI straight from the page. It works at once, and it puts the key in the file every visitor downloads, which is the first of the two fixes that leave you worse off. You can see that mistake from outside. Our free scan reads your live app's JavaScript the way a stranger would and names every key it finds, including whether it is one that belongs there. It takes about 20 seconds and needs no account: scan your app.

Keeping the key out of the page after the next change

A function you fixed today can be rewritten the next time you ask your builder for a feature, and the quickest way past a CORS error is the one that puts your key in the page.

Reeve Monitor re-scans your live app every hour:

  • all nine checks every hour, on up to three apps
  • an email the day a re-scan grades your app lower than the one before; a secret key in your JavaScript caps the grade at D on its own
  • whether the app is up, every 60 seconds
  • a monthly report of what it saw

A function holding your project's secret key can change any row in your database for whoever calls it. Reeve Care keeps a copy of your Supabase database where nothing in your project can reach it.

  • an encrypted copy on a schedule, kept on Reeve's own storage in the EU, outside your Supabase account
  • each copy verified before it counts, by counting the rows in every table
  • a one-click restore that saves what is there now before it replaces anything
  • your uploaded files as well, once you connect a Storage credential
  • everything Monitor does

What to do now

What to do

  • Open your live app's browser console, press the button again, and read the end of the red CORS line.
  • If the line mentions the preflight request, add an OPTIONS branch at the very top of the function that returns the CORS headers.
  • If it does not, your function ran. Read its Invocations and Logs tabs, and check what it already did before anyone presses the button again.
  • Return the CORS headers on every reply, errors included, and import them from supabase-js 2.95.0 or later so the list stays current.
  • Keep a check on who is calling inside the function. The CORS header only tells browsers who may read the answer.

When the error in your app changes to Edge Function returned a non-2xx status code, the headers are working and the reply is getting through; that post reads it from there. The 10-minute security checklist covers what else a newly launched app tends to leave open.

FAQ

What does "Failed to send a request to the Edge Function" mean?

It means supabase-js asked your Supabase Edge Function for a reply and got none it was allowed to read. The request either failed on its way, for example because the device was offline, or the browser refused to hand the answer to your app under its cross-origin rules, called CORS. The browser keeps the reason from your code on purpose and writes it in its console instead.

Why does my Edge Function work from a terminal but not from my app?

Because only browsers apply CORS. A terminal, a script or another server sends the request and reads the whole answer. A browser first checks that the answer says your app may read it, and before a request like the one supabase-js sends it also asks the function for permission with a separate OPTIONS request. If either step fails, your app gets this error while the same call works everywhere else.

Did my Edge Function run if I got this error?

Read the red line in the browser console. If it mentions the preflight request, the browser stopped at the permission check and your request was never sent. If it does not, the request went through, the function did its work, and only the reply was held back. In that case check the function's Invocations and Logs tabs before pressing the button again, because a second press repeats the work.

How do I fix a CORS error on a Supabase Edge Function?

Make the function answer the OPTIONS request first, before any code that reads the body or checks the caller, and return the CORS headers on every response, error responses included. Supabase's CORS guide imports those headers from supabase-js, version 2.95.0 or later, so the list matches what the client sends. Redeploy the function after the change.

What is "Relay Error invoking the Edge Function"?

It is a third message from supabase-js, raised when Supabase's relay could not reach your function. It is neither a CORS refusal nor a reply from your own code, so check whether Supabase itself is having a problem before you change anything.

Is it safe to allow every origin in my Edge Function's CORS headers?

Only if the function checks who is calling. The CORS header tells browsers which web pages may read the answer; it does not stop anyone calling the function. Supabase's own headers allow every origin, which is fine for a function that checks the caller's token and lets any website use one that does not. Narrowing it to your domain does not close the function to scripts either.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.