You call a Supabase Edge Function from your app and the browser console shows one of these:
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
Before your real request, the browser sends an OPTIONS preflight. Either the function doesn't answer it, or it answers without the CORS headers. The fix: answer OPTIONS with the headers, and attach the same headers to every response, errors included.
I see this one a lot in apps built with Lovable and Bolt on Supabase, usually the first time a function is called from the browser instead of from the dashboard.
Why it happens
- OPTIONS isn't handled. The function treats the preflight like a normal call, fails to parse a body, and returns an error without headers.
- Errors skip the headers. The success path returns CORS headers, but a thrown error or a 400 doesn't, so the browser reports CORS instead of the real error.
-
A header isn't allowed. The client sends a header (for example
x-client-infoor a trace header) that's missing fromAccess-Control-Allow-Headers. - The function crashed. A function that fails to boot returns no headers at all, and the CORS message hides the crash.
Find the cause
- In the browser's network tab, find the
OPTIONSrequest and check its status and headers. - Open the function's logs in the Supabase dashboard. A crash or a thrown error shows up there, not in the browser.
- Call the function with curl. If it fails there too, the problem is the function, not CORS.
Fix 1: use the SDK's headers and answer the preflight
With supabase-js 2.95.0 or later, you can import the CORS headers from the SDK, so they stay in sync with the headers the client sends. Return them on the OPTIONS response and on every other response, then redeploy.
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 { name } = await req.json()
return Response.json({ message: 'Hello ' + name }, { headers: corsHeaders })
} catch (error) {
const message = error instanceof Error ? error.message : 'Bad request'
return Response.json({ error: message }, { status: 400, headers: corsHeaders })
}
})
The catch branch is the part people skip. Without headers on the error response, a simple bad request looks like a CORS failure in the browser.
The Supabase docs also describe a withSupabase wrapper that handles CORS and the preflight for you, if you'd rather not add the headers by hand.
Fix 2: narrow the allowed origin before launch
Access-Control-Allow-Origin: * is fine for public functions. For functions that act on a signed-in user, return your own domain instead.
Check the fix worked
- In the network tab, the
OPTIONSrequest returns 200 withAccess-Control-Allow-Origin. - Trigger an error on purpose (send a bad body). The browser should now show your error message, not a CORS error.
Stop it coming back
- Wrap every function in one helper that adds the headers, so a new function can't forget them.
- Read the function logs whenever the browser reports CORS. It's often a crash in disguise.
Quick answers
Why does it work with curl but not in the browser? curl doesn't enforce CORS and sends no preflight. Only browsers check the headers.
Do I need CORS for webhooks? No. Webhooks come from servers (Stripe, for example), which ignore CORS. They need signature checks instead.
Source: Supabase: CORS support for Edge Functions.
I first published this fix on our site: Fix CORS errors when calling a Supabase Edge Function. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free Launch Check.
I wrote this with AI assistance and reviewed it myself.
Top comments (0)