Why this happens
When a browser app calls supabase.functions.invoke(), it sends extra headers (authorization, apikey, x-client-info, content-type). That makes the browser send an OPTIONS preflight first. If the function doesn't reply to it with the right Access-Control-Allow-* headers, the browser blocks the real request and reports a CORS error.
The sneaky version: the function does handle CORS on success, but it crashes or returns a 400 or 500 without the headers. The browser then shows a CORS error instead of your real error message, and you go hunting in the wrong place.
Other causes that look like CORS:
- The function isn't deployed, or
invoke('name')doesn't match the function's name. - Your hardcoded header list is missing headers newer clients send, such as
x-retry-count, or the trace headers when trace propagation is turned on.
How to fix it, step by step
1. Use the shared CORS headers (supabase-js v2.95.0 and later), following Supabase's docs:
import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors';
export default {
fetch: async (req: Request) => {
// 1. Answer the preflight
if (req.method === 'OPTIONS') {
return Response.json({ ok: true }, { headers: corsHeaders });
}
try {
const { name } = await req.json();
// ... your logic
return Response.json({ message: `Hello ${name}!` }, { headers: corsHeaders });
} catch (error) {
// 2. Errors need CORS headers too
const message = error instanceof Error ? error.message : String(error);
return Response.json({ error: message }, { status: 400, headers: corsHeaders });
}
},
};2. On an older supabase-js, hardcode the headers in 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',
};3. Or let withSupabase do it. Supabase's @supabase/server wrapper handles CORS and preflight automatically:
import { withSupabase } from 'npm:@supabase/server@^1';
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => Response.json({ ok: true })),
};4. Redeploy with supabase functions deploy <name>, then confirm the name in your code matches: supabase.functions.invoke('<name>').
5. Read the function logs. In the dashboard under Edge Functions → your function → Logs (or the Cloud view's logs on Lovable Cloud), check whether your code ran at all, and what the real error was if it did.
6. Lock it down later. Once it works, you can replace '*' with your real origin, e.g. https://yourdomain.com.
Fix prompt for your AI builder
Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.
Calling our Supabase Edge Function "<function-name>" from the browser fails with a CORS error ("No 'Access-Control-Allow-Origin' header"). Please fix only that function:
1. Import corsHeaders from 'npm:@supabase/supabase-js@^2/cors' (or a _shared/cors.ts that includes authorization, x-client-info, apikey, content-type, x-retry-count).
2. Return early on OPTIONS with corsHeaders.
3. Wrap the logic in try/catch and include corsHeaders on every response, including 4xx and 5xx errors.
4. Check that the name in supabase.functions.invoke matches the deployed function name.
Don't change the function's business logic or other functions.How to catch this next time
CORS breaks come back when an AI edit rewrites a function and drops the shared headers, or when you upgrade supabase-js and the client starts sending a new header.
Heedza checks on a schedule that your live signup, login and checkout pages load and show their forms, and it emails a screenshot and a fix prompt when they don't. It doesn't call your Edge Functions, so a function that only fails on submit still needs a quick live test after each deploy. See what Heedza checks with the free scan.