Security basics
"Edge Function returned a non-2xx status code" in Supabase
"Edge Function returned a non-2xx status code" means your Supabase function answered with an error. Here is where to read the real one.

In short
- "Edge Function returned a non-2xx status code" is what supabase-js says about any reply from your function outside the 200s. The real reason came back in the same reply, and the function's logs hold the rest.
- Read the status number first. 401 means the request was refused before your code ran, 404 that no function by that name is deployed, 500 that your code failed, 546 or 504 that it ran out of resources or time.
- Two quick fixes make the message stop and open a hole: calling the API from your frontend with the key in it, and switching off JWT verification with no check of your own in its place.
You press the button in your app that calls an Edge Function, and instead of a result your app shows this:
Edge Function returned a non-2xx status code
It is the same sentence whatever went wrong. A missing key, a function that crashed, a request turned away before your code ran: all of them read "Edge Function returned a non-2xx status code", so the message on its own tells you almost nothing about what to fix.
What it does not tell you is that the reason has already arrived. The error came back in the same reply as that sentence, and supabase-js does not read it out to you. Searching the sentence gets you other people's guesses. Reading the reply gets you your own answer.
What does "Edge Function returned a non-2xx status code" mean?
Your function answered, and the answer was an error. That is all the sentence says.
Every reply on the web carries a status code, a three-digit number saying how
the request went. Anything in the 200s means it worked. When your app calls a
function through supabase.functions.invoke, supabase-js looks at that number,
and if it is outside the 200s it hands your app a fixed sentence in place of the
data. The sentence is written into
supabase-js itself,
word for word, and the error it comes with is called a FunctionsHttpError.
Think of the call as a letter you post to a back office. The office either answers what you asked or sends the letter back. When it comes back, supabase-js reads the stamp on the envelope and tells you it was returned. It does not open the envelope. The note inside says why, and the office kept a logbook of everything that happened while your letter was there.
Two close relatives look similar and mean something else. Failed to send a request to the Edge Function means no readable reply came back at all, and in
a browser that is often a missing CORS header, which
the post on that message
covers. And if several unrelated parts of your app broke at the same moment,
check whether it is Supabase or your app
before you open any code.
Where is the real error?
In two places, and you can read both without changing a line of code.
The reply itself. Open your live app in Chrome, Edge or Firefox, press F12,
choose the Network tab and repeat whatever failed. Click the request whose
address contains /functions/v1/ followed by your function's name. Its
Status column has the number, and its Response tab has whatever the
function sent back with it: a message your own code wrote, a short JSON body
from Supabase's platform, or the words Internal Server Error.
If you, or the tool that writes your code, can change the call, Supabase's
error handling guide
shows how to read the same reply inside the app, because the original response
travels with the error as error.context:
const { data, error } = await supabase.functions.invoke('your-function', { body })
if (error) console.log(error.context.status, await error.context.text())
The function's logbook. In the Supabase dashboard, open Edge Functions,
pick the function, and read two tabs. Invocations lists every request with
its status code and how long it took. Logs holds what Supabase's
logging guide calls
platform events, uncaught exceptions and custom log messages, which means
anything your code printed with console.log or console.error. Reproduce the
failure once, note the time, and read both tabs at that minute.
Read the status code first
The number tells you how far your request got before it was turned back, and that decides where the reason is written down.
| Status | What happened | Where the reason is |
|---|---|---|
401 | Refused before your code ran, or your code refused it | The reply. Invalid JWT or Missing authorization header is the platform |
404 | No function with that name is deployed in this project | The reply reads Requested function was not found |
500 | Your code ran and threw an error, or returned a 500 on purpose | The Logs tab |
503 | The function failed to start at all | The Logs tab: an import, a syntax error or the function's configuration |
504 | No reply within 150 seconds | The Invocations tab, then whatever your code was waiting on |
546 | It ran out of CPU time or memory | The Logs tab says which |
| Any other 4xx | Your own code chose it, for example by passing on a service's answer | The reply |
The meanings come from Supabase's status code reference and its limits page. A 404 needs no more than this table: check the function's name in your app against the list in the dashboard, and check it is deployed to the project your app talks to. The other rows have a section each below.
500: your function ran and failed
Something in your code threw an error, and the Logs tab has the line.
Supabase's page on this error
splits it in two. If the reply says exactly Internal Server Error, your code
crashed and the log shows the type of error and the line it happened on, such as
TypeError: Cannot read properties of undefined. If the reply says anything
else, your code wrote that message itself and returned the 500 on purpose, so
search the function for the status it sent.
One cause is worth ruling out before any other in a function that calls OpenAI, Stripe or any paid API: a secret that exists on your machine and not on your live project.
Inside the function, a secret is read with Deno.env.get('OPENAI_API_KEY').
When no secret by that name is set, Deno's documentation says this
returns undefined instead of
failing. So the function carries on, builds a request with the word undefined
where the key should be, and sends it. OpenAI answers with its own 401,
"Incorrect API key provided", and your function either passes that on or falls
over trying to read a reply it did not expect. In the envelope picture, the
office opened your letter, went to the drawer where the key should be, found it
empty, and sent back a note about a wrong key.
The repair is on the Edge Function Secrets page of your Supabase project.
Supabase's secrets guide
says a function reads a new secret immediately, so you do not redeploy, and that
no secret name may start with SUPABASE_, because that prefix is reserved for
the values Supabase sets for you. A secret you set for local development stays
on your machine; the live project has its own list.
401: refused before your code ran
If the reply says Invalid JWT or Missing authorization header, your function
never saw the request. Supabase's platform turned it away at the door.
That door is a check called verify_jwt, on by default, which reads the
Authorization header of every request before your code runs. Supabase's
page on the 401
lists three ways to fail it:
- The key does not match your project's keys. For example a key copied from another project, or an old one still set somewhere after a rotation.
- There is no
Authorizationheader at all. supabase-js adds one for you. A plainfetchto the function's address, or a webhook from another service, does not. - Your project uses Supabase's newer signing keys. The built-in check only understands the older format, and Supabase's fix is to switch the check off for that function.
That last point goes further than one case. Supabase now recommends turning the built-in check off and managing authentication directly in your function code. The recommendation has two halves, and the second is the one that gets dropped.
If the 401 came with a message your own code wrote, the platform let it
through and your function refused it, or passed on a 401 from the service it
called, which is often the empty secret from the 500 section. This is
also a different check from the one behind PGRST301 on your database queries,
which has a page of its own. If the function's
log shows invalid claim: missing sub claim, it asked Supabase Auth who was
calling and was handed your project's key instead of a signed-in user, which
that message's own post traces.
546 and 504: out of resources or out of time
Supabase stops a function that uses too much, and the two numbers say which limit it hit.
546 is compute. The limits page
gives a function 256 MB of memory and each request 2 seconds of CPU time. Go
over either and the reply reads
Function failed due to not having enough compute resources (please check logs),
with the code WORKER_RESOURCE_LIMIT (older logs say WORKER_LIMIT). The Logs
tab says CPU Time exceeded or Memory limit exceeded. Supabase's
page on the 546
gives image processing, AI embeddings and web scraping as its examples, and its
answer is to do less per request or hand the heavy part to another service.
504 is time. A function that sends no reply within 150 seconds gets a 504. The CPU limit counts only the time your code is actually computing: the limits page says it does not include async I/O, which is what waiting on the network is. So a function that sits waiting for a slow AI model is not spending CPU. It is running down the 150-second clock, and when that runs out you get a 504 rather than a 546.
Two fixes that make it worse
Two changes make this message go away within minutes, and both leave you worse off than the error did.
Calling the API from the page instead. The quickest way to stop a failing
function from failing is to stop using it: put the key in a VITE_ variable
and call OpenAI straight from your frontend. It works on the first try. It also
puts the key in the file every visitor downloads, because that prefix is
an instruction to compile the value into your bundle,
and anyone can read it there. Across the live apps we have scanned, an OpenAI key
turned up in the JavaScript of 33, and
what a stranger can do with one
has a post of its own.
Switching off the check and stopping there. Clearing a 401 by turning off
verify_jwt is the first half of Supabase's own advice. Supabase's
configuration guide
is plain about what it does alone:
"it will allow anyone to invoke your Edge Function without a valid JWT". Your
function's address is in your frontend for anyone to read. If the function
holds an OpenAI key, a stranger can now spend it through you. If it uses the
project's secret key, which Supabase places in every function's environment, a
stranger's request runs with a key that gets past every row level security rule
you wrote.
The second half, a check on who is calling written into the function, is a few lines. Moving a secret into an Edge Function has them, and it explains why even the default check lets more callers through than its name suggests.
You can see the first mistake from outside, which is how we counted it. 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 where you put it
The fix you make today can be undone by next week's prompt. A builder asked to make a feature work again is free to take the shortcut above, and your app keeps working while it does.
Reeve Monitor checks every hour so you do not have to:
- 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 running with your secret key gets past every row level security rule. 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
- Reproduce the failure once and note the time.
- Open the request in your browser's Network tab and read its status and its response before you change anything.
- Read the function's Invocations and Logs tabs in the Supabase dashboard at that minute.
- On a 500 from a function that calls a paid API, check the secret is set on the live project's Edge Function Secrets page. No redeploy is needed after.
- On a 401 from the platform, fix the key or switch the built-in check off, and add a check on who is calling inside the function in the same change.
- Never clear this error by moving the key into a
VITE_variable.
The check on who is calling is the step that is easiest to skip, and the Edge Function secrets walkthrough covers it in full. The 10-minute security checklist covers what else tends to be left open in a newly launched app.
FAQ
What does "Edge Function returned a non-2xx status code" mean?
It means your Supabase Edge Function replied with an error status, any number outside the 200s, and supabase-js turned that reply into one fixed sentence. The sentence is the same for a crash, a missing key, a request refused before your code ran and a timeout. The reply itself, which says which of those it was, is still attached to the error, and the function keeps a log of its own.
How do I see the real error behind it?
Open your live app, press F12, choose the Network tab and repeat whatever failed. Click the request whose address contains /functions/v1/ and read its status and its response: that is what the function actually sent. Then open Edge Functions in your Supabase dashboard, pick the function, and read the Invocations and Logs tabs at the same time. In code, Supabase's guide reads the same reply with await error.context.json().
Why does my Edge Function work locally but fail when deployed?
Check first whether a secret exists on your machine and not on your live project. The local stack reads its own environment file, and the deployed function reads the secrets set on the project. A secret that is missing comes back as undefined rather than as an error, so the function carries on and fails later, usually when the service it calls refuses an empty key. Set it on the Edge Function Secrets page; Supabase says a new secret is read immediately, with no redeploy.
Should I turn off JWT verification to fix a 401?
Supabase now recommends turning the built-in check off and checking the caller inside your function instead. Both halves matter. Turned off on its own, the check stops nothing, and Supabase's own docs warn that this lets anyone invoke your function without a valid JWT. If the function holds an OpenAI key or the project's secret key, that is anyone on the internet spending your quota or reaching past your row level security rules.
What does error 546 mean on a Supabase Edge Function?
The function ran out of compute: more than 2 seconds of CPU time, or more than its memory allowance, on one request. The reply reads "Function failed due to not having enough compute resources (please check logs)", and the Logs tab says which limit it was, CPU Time exceeded or Memory limit exceeded. Waiting on a slow API does not count as CPU time; a function that waits too long gets a 504 instead.