Supabase Edge Function CORS error? Fix "blocked by CORS policy" and "Failed to send a request to the Edge Function"
Your Edge Function works when you test it with curl, but the live app shows "has been blocked by CORS policy" in the console, or supabase.functions.invoke() returns "Failed to send a request to the Edge Function". Checkout buttons, AI features and email sending built by Lovable, Bolt or Cursor all stop at this point. The fix is usually a few lines in the function, but the console often hides the real cause, so start by finding out which request fails. Most apps are fixed by step 4.
- Read the real error in the Network tab
- Answer the OPTIONS preflight first
- Use the official CORS headers, not an old copied list
- Put CORS headers on every response, including errors
- Check the function actually boots and is deployed
- Fix 401 "Invalid JWT" from the built-in auth check
- Call it the right way from your frontend
- Optional: allow only your own domains in production
- Lovable, Bolt and Cursor: what to ask the AI
- FAQ
1. Read the real error in the Network tab
A CORS error is the browser refusing to show your app a response, so the console message is often a cover for a different problem. Open DevTools → Network, trigger the call again and look for two requests to https://<project-ref>.supabase.co/functions/v1/<name>: an OPTIONS request (the preflight) and then your POST/GET. Typical console messages:
Access to fetch at 'https://abc.supabase.co/functions/v1/create-checkout'
from origin 'https://yourapp.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.
Request header field x-client-info is not allowed by
Access-Control-Allow-Headers in preflight response.
In code, supabase.functions.invoke() reports these as FunctionsFetchError: "Failed to send a request to the Edge Function", meaning the browser never got a usable response. If you instead get FunctionsHttpError: "Edge Function returned a non-2xx status code", CORS is fine: the function ran and returned an error, so read its body (step 7). Now check the status of the failing request:
- OPTIONS fails or has no CORS headers: the function doesn't answer the preflight (steps 2–3).
- OPTIONS is fine, POST shows 500 or "CORS error": your code threw or returned an error without CORS headers (step 4).
- 503 with
BOOT_ERRORor 404NOT_FOUND: the function didn't start or isn't deployed (step 5). - 401 "Invalid JWT" or "Missing authorization header": the platform's JWT check rejected the call before your code ran (step 6).
Why it works in curl or Postman: only browsers enforce CORS. A function that answers curl perfectly can still be unusable from your web app.
2. Answer the OPTIONS preflight first
Calls from supabase-js send authorization, apikey, x-client-info and a JSON content-type, so the browser always sends a preflight OPTIONS request first. Your function must answer it with a 2xx status and the CORS headers before doing anything else (before reading the body, checking auth or calling Stripe). AI-generated functions often parse await req.json() on line one, which throws on the empty OPTIONS request.
// supabase/functions/create-checkout/index.ts
import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors'
Deno.serve(async (req) => {
// 1. Preflight: answer immediately
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
// 2. Your real logic
const { priceId } = await req.json()
// ...
return new Response(JSON.stringify({ ok: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
})
Redeploy after the change (supabase functions deploy create-checkout, or ask Lovable/Bolt to redeploy the function) and retest in a fresh tab.
3. Use the official CORS headers, not an old copied list
Supabase now ships the header list with the SDK: since @supabase/supabase-js v2.95.0 you can import corsHeaders from npm:@supabase/supabase-js@^2/cors (as above), so the allow-list stays in sync with the headers the client libraries send. Alternatively, Supabase's withSupabase wrapper from npm:@supabase/server handles CORS and preflight for you.
Older tutorials (and a lot of AI-written code) hardcode a _shared/cors.ts. That still works if the list is complete. The current version from Supabase's docs:
// supabase/functions/_shared/cors.ts
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, x-retry-count, traceparent, tracestate, baggage',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
}
- Every header the browser sends must be listed. If you pass custom headers in
functions.invoke('name', { headers: { 'x-my-header': '…' } }), add them too, or you'll get "Request header field … is not allowed". - Methods: if you call with
method: 'DELETE'orPUT, it must be inAccess-Control-Allow-Methods. - Don't put the CORS headers only on the OPTIONS response. The real response needs
Access-Control-Allow-Originas well.
4. Put CORS headers on every response, including errors
This is the most common reason people say "I added corsHeaders and it still fails". If your code throws (a missing secret, bad JSON, a Stripe error), the platform returns a plain 500 without your CORS headers, and the browser reports it as a CORS error, hiding the real message. Wrap the logic and always return headers:
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const body = await req.json()
const result = await doWork(body)
return Response.json(result, { headers: corsHeaders })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('create-checkout failed:', message) // shows in the function logs
return Response.json({ error: message }, { status: 400, headers: corsHeaders })
}
})
Early returns count too: an if (!user) return new Response('Unauthorized', { status: 401 }) without headers turns into a CORS error in the browser. Search the function for every new Response( and Response.json( and make sure each one includes corsHeaders.
5. Check the function actually boots and is deployed
Open the function's logs (Supabase dashboard → Edge Functions → your function → Logs / Invocations; in Lovable Cloud: More → Cloud → Edge functions → View logs). If the code can't start, your CORS headers never run:
- 503
BOOT_ERROR"Function failed to start (please check logs)": filter the logs forworker boot error. Usually a syntax error or a broken import (for example a package version that doesn't exist). Fix it and redeploy. - Unhandled exception / 500 "Internal Server Error": the log shows the error type and line, e.g.
TypeError: Cannot read properties of undefined. Often a secret read at the top of the file that isn't set. - 404
NOT_FOUND: wrong function name ininvoke(), the function was never deployed to this project, or your frontend points at a different project URL than you think (checkVITE_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_URLin your host's environment variables).
Secrets: functions read API keys with Deno.env.get('STRIPE_SECRET_KEY'). Set them under Edge Function Secrets in the dashboard or with supabase secrets set STRIPE_SECRET_KEY=sk_live_...; deployed functions see new secrets immediately. A local supabase/functions/.env is only for local development, and secret names can't start with SUPABASE_. A paused Supabase project (or paused Lovable Cloud) also makes every function call fail.
6. Fix 401 "Invalid JWT" from the built-in auth check
By default, Edge Functions run a legacy JWT check before your code. When it fails you get a 401 with "Invalid JWT", "Invalid Token or Protected Header formatting" or "Missing authorization header", your function never executes, and in the browser it can look like yet another CORS failure. Common causes:
- Calling the function with raw
fetch()and noAuthorizationheader.supabase.functions.invoke()adds it for you. - Your project uses the new publishable/secret or asymmetric keys, which the legacy check doesn't understand.
- An expired session token, or a key from a different project.
Supabase now recommends turning the built-in check off and verifying auth in your function code. Per function, in supabase/config.toml:
[functions.create-checkout]
verify_jwt = false
Only do this if the function then checks the user itself (for example by calling supabase.auth.getUser() with the request's Authorization header) or is meant to be public, like a Stripe webhook. Otherwise anyone can call it. For local testing, supabase functions serve --no-verify-jwt does the same.
7. Call it the right way from your frontend
Prefer the Supabase client over hand-written fetch calls. It builds the right URL and adds the auth headers, and its errors tell you what happened:
import { FunctionsHttpError, FunctionsRelayError, FunctionsFetchError } from '@supabase/supabase-js'
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: { priceId },
})
if (error instanceof FunctionsHttpError) {
console.error('Function returned an error', await error.context.json())
} else if (error instanceof FunctionsRelayError) {
console.error('Relay error', error.message)
} else if (error instanceof FunctionsFetchError) {
console.error('Could not reach the function (CORS, network, not deployed)', error.message)
}
- If you must use
fetch, callhttps://<project-ref>.supabase.co/functions/v1/<name>and sendAuthorization: Bearer <token>plusapikey, and list every header you send in the function's allow-list. - Don't use
credentials: 'include'withAccess-Control-Allow-Origin: *. Browsers reject a wildcard origin on credentialed requests. Supabase calls don't need cookies, so just remove it. - Never put a service role or secret key in frontend code to "fix" a 401. It gives everyone full database access.
8. Optional: allow only your own domains in production
Access-Control-Allow-Origin: * is fine for functions protected by auth. If you want to restrict which sites can call a function from the browser, echo back only origins you trust, and remember your preview, custom-domain and localhost addresses:
const ALLOWED = new Set([
'https://yourapp.com',
'https://www.yourapp.com',
'https://yourapp.lovable.app',
'http://localhost:5173',
])
function cors(req: Request) {
const origin = req.headers.get('Origin') ?? ''
return {
...corsHeaders,
'Access-Control-Allow-Origin': ALLOWED.has(origin) ? origin : 'https://yourapp.com',
'Vary': 'Origin',
}
}
Use cors(req) in place of corsHeaders on every response. A missing origin here is a classic "works in preview, CORS error on my custom domain" bug, so add each new domain when you connect it. CORS is not a security boundary for your data, though: anyone can still call the function from curl, so keep real checks (auth, RLS) in place.
9. Lovable, Bolt and Cursor: what to ask the AI
In Lovable you don't edit Edge Functions by hand: describe the fix in the project chat. Paste the exact console error plus the failing log entry from More → Cloud → Logs, and ask:
"The create-checkout edge function fails from the browser with a CORS error. Handle the OPTIONS preflight first, import corsHeaders from @supabase/supabase-js/cors, include corsHeaders on every response including errors and early returns, wrap the logic in try/catch with console.error, and redeploy."
- Bolt / Cursor with your own Supabase project: the function code lives in
supabase/functions/<name>/index.ts. The AI can edit it, but it isn't live until you runsupabase functions deploy <name>(or deploy from the dashboard). Editing the file and testing the live site without redeploying is a very common loop. - Ask for one change at a time and retest the Network tab after each deploy.
- If the function also writes to the database and nothing shows up, that's usually row-level security: see Supabase insert not working? and Supabase returns an empty array?
Still blocked? Send us your live URL, the function name and a screenshot of the failing OPTIONS/POST request through the free check form or by email to fix@getvibefix.com. We'll tell you exactly what's wrong for free, before you pay anything.
FAQ
Why does my Supabase Edge Function work in curl or Postman but give a CORS error in the browser?
Only browsers enforce CORS. The browser first sends an OPTIONS preflight request and needs it answered with a 2xx status and Access-Control-Allow-Origin and Access-Control-Allow-Headers. curl and Postman skip that check, so a function that doesn't handle OPTIONS still looks fine there.
What does "Failed to send a request to the Edge Function" mean?
It's supabase-js's FunctionsFetchError: the browser never got a usable response. Usually the CORS preflight failed, the function crashed before returning CORS headers, it isn't deployed (404), or the project is paused. "Edge Function returned a non-2xx status code" (FunctionsHttpError) is different: the function ran and returned an error you can read with error.context.json().
What CORS headers does a Supabase Edge Function need?
Access-Control-Allow-Origin (for example *), Access-Control-Allow-Headers covering authorization, x-client-info, apikey and content-type (Supabase's current list also includes x-retry-count, traceparent, tracestate and baggage) plus any custom headers you send, and Access-Control-Allow-Methods if you use methods other than GET/POST. With supabase-js v2.95.0+ import corsHeaders from @supabase/supabase-js/cors.
Why do I still get a CORS error after adding corsHeaders?
Usually because an error path doesn't include them: an uncaught exception, a missing secret, or an early return like a 401 without headers makes the platform or your code send a response without CORS headers. Other causes: the function wasn't redeployed, it fails to boot (503 BOOT_ERROR), or the built-in JWT check returns 401 before your code runs.
Should I set verify_jwt = false to fix a CORS or 401 error?
Only if the function then verifies the user itself or is meant to be public, like a Stripe webhook. Supabase now recommends disabling the legacy JWT check and handling auth in code, especially for projects on the new API keys, but turning it off without your own check lets anyone call the function.
Still stuck? We'll fix it at a flat price.
VibeFix fixes broken Lovable, Bolt, Cursor, Replit and v0 apps. Request a free check first, and we'll tell you what's wrong and which plan fits before you pay. If we can't fix it, you get a full refund.
Already know you need help? Quick Fix, $49 (one bug, 24h) · Standard Rescue, $129 · Deploy-Ready, from $199