Fixing CORS Errors in Self-Hosted Supabase: A Guide

CORS errors blocking your self-hosted Supabase app? Learn how to configure Kong, Edge Functions, and Storage to allow your frontend origins the right way.

Cover Image for Fixing CORS Errors in Self-Hosted Supabase: A Guide

You deploy your self-hosted Supabase stack, point your frontend at it, and the first signUp() call dies in the browser console:

Access to fetch at 'http://your-server:8000/auth/v1/signup' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

It's one of the most common walls people hit after a fresh self-hosted install. On Supabase Cloud, CORS just works—the platform handles it for you. The moment you self-host, that responsibility moves to you, and it's split across several services that each behave differently. This guide explains why CORS breaks on self-hosted Supabase and exactly how to fix it for the REST API, Auth, Storage, and Edge Functions.

Why CORS Breaks on Self-Hosted (But Not Cloud)

CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When your JavaScript on https://app.example.com calls an API on a different origin—say http://your-server:8000—the browser first sends a "preflight" OPTIONS request asking the server: are you OK with this origin, these headers, these methods? If the server doesn't answer with the right Access-Control-Allow-* headers, the browser blocks the real request before your code ever sees a response.

Supabase Cloud ships with CORS configured at its edge. The self-hosted Docker stack does not enable a permissive CORS policy by default, and the official self-hosting docs say little about it—which is why GitHub issues like "where in the config do I allow CORS?" keep reappearing. The key thing to understand is that almost all your API traffic flows through Kong, the API gateway, so that's where most of the fix lives.

Configuring CORS in Kong (REST, Auth, Storage, Realtime)

In a standard Docker deployment, Kong sits in front of PostgREST (/rest/v1), GoTrue/Auth (/auth/v1), Storage (/storage/v1), and Realtime. Configure CORS once on Kong and you cover all of them. If you want a deeper tour of the gateway itself, see our Kong API gateway configuration guide.

Kong reads a declarative config file—usually volumes/api/kong.yml. Add the cors plugin to the global plugins: block:

plugins:
  - name: cors
    config:
      origins:
        - https://app.example.com
        - https://staging.example.com
      methods:
        - GET
        - POST
        - PUT
        - PATCH
        - DELETE
        - OPTIONS
      headers:
        - Accept
        - Authorization
        - Content-Type
        - apikey
        - x-client-info
        - x-supabase-api-version
      exposed_headers:
        - Content-Range
        - Content-Profile
      credentials: true
      max_age: 3600

A few things that trip people up here:

  • List explicit origins, not *, when using credentials. If credentials: true (which you need for cookie-based auth), the browser will reject a wildcard Access-Control-Allow-Origin: *. Enumerate your real origins instead.
  • Include apikey and x-client-info in headers. The Supabase JS client sends these on every request. Omit them and the preflight fails even though your origin is allowed.
  • Keep Content-Range in exposed_headers. PostgREST returns pagination counts there; without it, range/count queries look empty in the browser.

After editing kong.yml, restart the gateway so it reloads the declarative config:

docker compose restart kong

Then verify the preflight from your machine:

curl -i -X OPTIONS 'http://your-server:8000/auth/v1/signup' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST'

You should see Access-Control-Allow-Origin: https://app.example.com in the response headers. If you do, the browser will too.

Edge Functions Need CORS Handled in Code

Here's the catch that surprises everyone: Edge Functions don't go through Kong's CORS plugin. They're fully customizable Deno functions that you control, so you have to handle CORS inside the function itself—including answering the preflight OPTIONS request manually.

The standard pattern:

const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  'Access-Control-Allow-Headers':
    'authorization, x-client-info, apikey, content-type',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
};

Deno.serve(async (req) => {
  // Answer the browser's preflight first
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  const data = { message: 'Hello from a self-hosted edge function' };

  return new Response(JSON.stringify(data), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });
});

Forget the OPTIONS branch and your function works perfectly in curl but fails in the browser—a classic "it works on the server but not in my app" trap. For the broader setup, see our Edge Functions guide for self-hosted Supabase.

Storage Uploads and Direct Browser Access

Most Storage traffic routes through Kong, so the gateway plugin above usually covers signed uploads and downloads. The exception is when you've pointed Storage at an external S3-compatible backend (MinIO, R2, Backblaze) and your browser uploads directly to that bucket. In that case the bucket itself needs a CORS policy—Kong never sees those requests. Set the allowed origins, methods, and headers on the bucket via your provider's console or s3api put-bucket-cors, mirroring the origins you listed in Kong.

The Gotchas That Waste an Afternoon

Once Kong is configured, the remaining CORS failures almost always come from one of these:

  1. Duplicate CORS headers from a reverse proxy. If you run Nginx, Traefik, or Caddy in front of Kong and also add add_header Access-Control-Allow-Origin there, the browser sees two values and rejects the response with "multiple values not allowed." Pick one layer to own CORS—Kong—and strip it from the proxy. Our reverse proxy guide covers clean proxy configs.
  2. Trailing slashes and port mismatches. https://app.example.com and https://app.example.com/ are different origins to a strict matcher, and so are http vs https and :443 vs no port. Match exactly what the browser sends in the Origin header.
  3. API_EXTERNAL_URL and SITE_URL are wrong. These environment variables tell Auth what URLs to trust for redirects and links. If they still point at localhost after you moved to a real domain, auth flows misbehave in ways that look like CORS but aren't. See redirect and site URL configuration and the environment variables reference.
  4. Confusing CORS with auth errors. A 401 with no CORS headers shows up as a CORS error in the console because the error response also lacks the headers. Check the actual status code in the Network tab before blaming CORS.

How Supascale Removes the CORS Guesswork

CORS on self-hosted Supabase is fixable, but it's exactly the kind of cross-cutting, easy-to-misconfigure task that makes people abandon self-hosting. You're editing kong.yml by hand, restarting containers, reconciling reverse-proxy headers, and keeping API_EXTERNAL_URL in sync with whatever domain you're on this week.

Supascale handles the moving parts for you. When you bind a custom domain with automatic SSL, the gateway and external URLs are configured together, so origins line up out of the box and you avoid the http/https and port mismatches above. Auth providers and redirect URLs are managed through a UI instead of hand-edited env files, removing a whole class of "looks like CORS" failures. You still get full control over your stack and your data—no vendor lock-in—without babysitting gateway config every time you add a frontend origin.

Conclusion

CORS errors on self-hosted Supabase come down to three things: configure the cors plugin in Kong for your REST, Auth, Storage, and Realtime traffic; handle CORS (and the OPTIONS preflight) manually inside Edge Functions; and make sure no second layer—reverse proxy or external bucket—is fighting Kong over the headers. Get those right, list your exact origins, and the browser stops blocking your app.

If you'd rather skip the manual gateway wrangling entirely, see what Supascale automates and spin up a properly configured self-hosted instance in minutes.

Further Reading