Article
Supabase edge function "blocked by CORS policy": the five causes and the fix
Published on
The app calls an edge function, nothing happens, and the browser console has this in red:
Access to fetch at 'https://abcd.supabase.co/functions/v1/send-invoice'
from origin 'https://www.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
It often worked in the preview and broke on the real domain, or it worked yesterday and broke after the AI changed something in the function. Asking the AI to "fix CORS" tends to produce a new header list and the same error, because the message names a symptom, not a cause.
What the error actually means
A browser will not let a page on www.example.com read a response from
abcd.supabase.co unless that response says it may, with an
Access-Control-Allow-Origin header. Calls that send JSON or an
Authorization header, which is every supabase.functions.invoke() call,
first send a "preflight": an OPTIONS request asking whether the real request
is allowed. If the preflight, or the real response after it, comes back without
the right headers, the browser blocks it and shows this message.
The important consequence: any response without CORS headers looks like a CORS error, including a crash, a 404 and a 401. So the first step is not editing headers, it is finding out what the function really answered.
Step one: look at the Network tab, not the console
Open the browser's developer tools, go to Network, and trigger the call
again. You will see one or two requests to /functions/v1/<name>:
- An
OPTIONSrequest that failed or returned something other than 200: section 1. - An
OPTIONSthat passed and aPOSTwith status 500 or 400: section 2. - A preflight error that mentions a specific header name: section 3.
- A 404: section 4.
- A 401: section 5.
Then open the function's logs in the Supabase dashboard (Edge Functions, the function, Logs). A crash there with a timestamp matching your click settles it.
1. The function does not answer the preflight
The most common cause in AI-written functions: the code goes straight to
await req.json(). The preflight has no body, so the function throws, returns
a 500 without headers, and the browser reports CORS.
Supabase's documented fix, for supabase-js 2.95.0 and later, is to import the
headers from the SDK and answer OPTIONS before anything else:
import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors'
export default {
fetch: async (req) => {
if (req.method === 'OPTIONS') {
return Response.json({ ok: true }, { headers: corsHeaders })
}
try {
const { name } = await req.json()
return Response.json(
{ message: `Hello ${name}!` },
{ headers: corsHeaders },
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return Response.json(
{ error: message },
{ status: 400, headers: corsHeaders },
)
}
},
}
Projects generated before that SDK version usually keep the headers in
supabase/functions/_shared/cors.ts and import them from there. That works
too, as long as the OPTIONS branch exists and comes first.
2. Errors go out without the headers
The function handles the preflight, the real request fails, and the error
response is built without corsHeaders. The browser hides your error message
behind a CORS error, so a missing environment variable or a database error
looks exactly like a configuration problem.
Every response the function can return needs the headers: success, the
catch, and every early return for validation or "not found". Search the
function for new Response( and Response.json( and check each one. Once the
error responses carry the headers, the console will show the real message,
which is usually the actual bug.
3. The header list is shorter than what the client sends
If the console says something like Request header field x-client-info is not allowed by Access-Control-Allow-Headers in preflight response, the function
answers the preflight but does not list every header the client sends.
supabase-js sends authorization, x-client-info, apikey and
content-type, and newer versions can also send x-retry-count and, when
trace propagation is turned on, traceparent, tracestate and baggage.
A hardcoded list copied from an old tutorial often stops at the first four. The current list from Supabase's documentation:
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, x-retry-count, traceparent, tracestate, baggage',
}
The SDK import from section 1 keeps this list current for you, which is the main reason to prefer it. After upgrading the SDK, redeploy the function: the headers are baked in at deploy time.
'*' as the allowed origin is fine for these calls, because supabase-js
authenticates with the Authorization header, not with cookies. The real
protection is that the function checks who is calling, not the origin header.
4. The function is not deployed, or has another name
A 404 has no CORS headers either. It happens when the AI renamed the function
folder, when the code calls invoke('sendInvoice') but the function is
send-invoice, or when the function was written but never deployed to the
project the app points at. That last one is common when the preview uses one
Supabase project and production another.
Check the list under Edge Functions in the dashboard of the project in your
production VITE_SUPABASE_URL. With the CLI, deploy it explicitly:
supabase functions deploy send-invoice
5. A 401 before your code runs
By default Supabase requires a valid JWT in the Authorization header and
rejects the call with a 401 before your function starts, so nothing in your
code runs and the CORS headers you added never get the chance to apply. The
Network tab shows the 401 even when the console only talks about CORS. A user
who is not logged in yet, a session that expired, or a plain fetch() call
that never attached the session all end here.
If the function is meant for logged-in users, fix the call: use
supabase.functions.invoke(), which sends the session for you, and make sure
the user is logged in first. If it really is public, such as a contact form or
a webhook, deploy it with --no-verify-jwt and check the caller inside the
function instead. Stripe webhooks are the classic case, covered in Lovable
Stripe payments not working in
production.
Check it from the terminal
You can test the preflight without the app. Replace the project, function name and origin:
curl -i -X OPTIONS 'https://abcd.supabase.co/functions/v1/send-invoice' \
-H 'Origin: https://www.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization, x-client-info, apikey, content-type'
A working function answers 200 with access-control-allow-origin and
access-control-allow-headers in the response. Anything else tells you which
section above applies before you open the code.
When CORS is not the real problem
Most of the time the CORS message is covering a function that fails for another reason: a secret that exists in the preview project but not in production, a database write blocked by row-level security, or an external API key that was never set. It is one of the ways an app works in preview and breaks in production.
If you are several rounds into "fix the CORS error" and each fix moves the problem somewhere else, the app needs someone to read the functions and the data model together. That is our vibe code rescue audit: 450 EUR, 48 working hours, a written report of what is broken and what each fix costs, with the fee deducted if you continue into the rescue (1,500-6,000 EUR, fixed scope).