BusiRocket

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 OPTIONS request that failed or returned something other than 200: section 1.
  • An OPTIONS that passed and a POST with 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).

Next step

Does this problem sound like yours?

Tell us about it and we will say how we would approach it, in what order and at what cost.