Put your own domain in front of a Supabase project instead of shipping *.supabase.co URLs to your users without paying for Supabase's custom domain add-on.
This started as a quick Apache vhost and turned into a small case study in how many ways CORS can quietly make you waste half a day.
Supabase gives every project a public API at https://YOUR_PROJECT_ID.supabase.co. Totally fine for a side project, less fine when you want:
- β
A professional-looking API domain (
api.yourbrand.com, notxyzabc123.supabase.co) - β No recurring per-project custom-domain fee
- β Full control over routing, headers, and CORS at the edge
My Creative Ad-Hoc Solution? A lightweight Apache reverse proxy on a box you already run, forwarding to Supabase behind the scenes. Your frontend only ever talks to your domain.
Supabase isn't one service, it's several services all reachable under one project URL:
| Path | Service | What it's for |
|---|---|---|
/auth/v1/* |
Auth (GoTrue) | login, signup, token refresh, logout |
/rest/v1/* |
REST (PostgREST) | supabase.from('table') queries |
/storage/v1/* |
Storage | file upload / download |
/realtime/v1/* |
Realtime (WebSocket) | live subscriptions |
/functions/v1/* |
Edge Functions | your own custom serverless code |
The proxy's job: match each prefix, forward to the same path upstream, attach the right headers, and set CORS correctly on the way back out. That last part is where all the time went.
<If "%{REQUEST_METHOD} == 'OPTIONS'">
Header always set Access-Control-Allow-Origin "*"
</If>Initially, this worked fine when calling Supabase functions because my edge functions set their own headers and we only needed to add -Allow-Origin on first ping from the browser (which doesn't return the actual edge function so it wouldn't include those custom headers.)
When I started using the proxy for other services, like /auth, preflight would pass but the real request still failed. Fun fact, browsers require Access-Control-Allow-Origin on the actual response too, not just the OPTIONS check. Fix: drop the conditional, set it on every response.
That fixed the missing CORS header for /auth but then broke everything else. Not only did many of my edge functions set their own Access-Control-Allow-Origin but
Supabase's own gateway (Kong) sets it too. So having Apache also set those headers mucked everything up. Turns out, misconfigured (or doubled) CORS headers cause browsers to reject them outright.
# β
strip whatever came from upstream, then set exactly one
Header unset Access-Control-Allow-Origin
Header always set Access-Control-Allow-Origin "*"CORS errors are a red herring. You make one silly mistake and get back an irrelevent error sending you on a wild goose chase.
RewriteRule ^/auth/(.*)$ https://YOUR_PROJECT_ID.supabase.co/auth/v1/$1 [P,L]The incoming path was already /auth/v1/token and this rule bolted on a second v1, producing .../auth/v1/v1/token β 404. And a 404 has no CORS headers, so the browser reported it as a CORS error instead of a routing bug.
# β
fixed: no extra v1
RewriteRule ^/auth/(.*)$ https://YOUR_PROJECT_ID.supabase.co/auth/$1 [P,L]π§ Lesson: a CORS error on a proxy is often a routing error wearing a disguise. Check the actual proxied URL first.
Header always set Access-Control-Allow-Headers "authorization, apikey, content-type"Worked, until a client library update sent one more header (x-supabase-api-version) that wasn't on the list. Rather than patch this file every time a client adds a header, we reflect back whatever the browser actually asked for:
# β
capture what the browser is requesting permission for...
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteCond %{REQUEST_URI} ^/auth/
RewriteRule ^ - [E=ACRH:%{HTTP:Access-Control-Request-Headers},R=204,L]
# ...and echo it straight back
Header always set Access-Control-Allow-Headers "%{ACRH}e" env=ACRHFuture-proof, zero maintenance.
I started the proxy to access my edge functions. Then realized the /auth service worked differently and needed its own explicit route. This, again, worked great until I wanted to use the Supabase /rest endpoint to make actual database queries.
Initially, only /auth/* had an explicit route and everything else fell into the catch-all pointed at /functions/v1/. So hitting /rest/v1 meant every table query proxied to a nonexistent function path and 404'd (see v3's lesson above: never trust a CORS error as the real problem).
# β
give REST its own explicit route, same shape as auth
RewriteCond %{REQUEST_URI} ^/rest/(.*)$
RewriteRule ^/rest/(.*)$ https://YOUR_PROJECT_ID.supabase.co/rest/$1 [P,L]
ProxyPassReverse /rest/ https://YOUR_PROJECT_ID.supabase.co/rest/Storage and Realtime got the same treatment ahead of time, before we actually needed them.
Another silly mistake and lesson learned: One endpoint kept failing preflight no matter what we changed. Instead of guessing from the browser console, we asked the server directly:
curl -sS -D - -o /dev/null -X OPTIONS "https://YOUR_DOMAIN/auth/v1/logout" \
-H "Origin: https://yourfrontend.com" \
-H "Access-Control-Request-Method: POST"Response: a generic 200 OK, no CORS headers, identical across three completely different routes. That's not something our config would ever produce, meaning our config wasn't the one running. Turns out the file on the server had a typo'd filename, and a prior edit had dropped SSLCertificateKeyFile, so Apache was quietly still serving the old vhost.
π§ Lesson: when behavior doesn't match the config in front of you, stop editing and go verify it's actually the config that's live.
curlbypasses the browser's abstracted errors and shows you the truth in one shot.
- β CORS headers set on every response, not just preflight
- β One route per Supabase service. No catch-all swallowing a real endpoint
- β Allowed headers reflected dynamically instead of hardcoded
- β
Exactly one
Access-Control-Allow-Origin, ours, always - β
apikeyonly injected where Supabase's gateway actually requires it (auth/rest/storage). Edge Functions verify the JWT themselves
sudo a2enmod rewrite headers proxy proxy_http proxy_wstunnel ssl setenvifsudo THIS_VHOST_FILE.conf /etc/apache2/sites-available/YOUR_DOMAIN.conf
sudo a2ensite YOUR_DOMAIN.confsudo apache2ctl configtestsudo systemctl reload apache2<VirtualHost *:443>
ServerName YOUR_DOMAIN
Include /etc/letsencrypt/options-ssl-apache.conf
SSLProxyEngine On
RewriteEngine On
SetEnvIf Request_URI "^/(auth|rest|storage)/" IS_SUPABASE_GATEWAY=1
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteCond %{REQUEST_URI} ^/(auth|rest|storage)/
RewriteRule ^ - [E=ACRH:%{HTTP:Access-Control-Request-Headers},R=204,L]
Header unset Access-Control-Allow-Origin
Header always set Access-Control-Allow-Origin "*"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" env=ACRH
Header always set Access-Control-Allow-Headers "%{ACRH}e" env=ACRH
Header always set Access-Control-Max-Age "3600" env=ACRH
SetEnvIf Authorization "^(.*)" PROXY_AUTH=$1
ProxyPreserveHost Off
RequestHeader set Host "YOUR_PROJECT_ID.supabase.co"
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
RequestHeader set X-Forwarded-Proto https
RequestHeader set apikey YOUR_SUPABASE_PUBLISHABLE_KEY env=IS_SUPABASE_GATEWAY
RequestHeader set Authorization "%{PROXY_AUTH}e" env=PROXY_AUTH
RewriteCond %{REQUEST_URI} ^/auth/(.*)$
RewriteRule ^/auth/(.*)$ https://YOUR_PROJECT_ID.supabase.co/auth/$1 [P,L]
ProxyPassReverse /auth/ https://YOUR_PROJECT_ID.supabase.co/auth/
RewriteCond %{REQUEST_URI} ^/rest/(.*)$
RewriteRule ^/rest/(.*)$ https://YOUR_PROJECT_ID.supabase.co/rest/$1 [P,L]
ProxyPassReverse /rest/ https://YOUR_PROJECT_ID.supabase.co/rest/
RewriteCond %{REQUEST_URI} ^/storage/(.*)$
RewriteRule ^/storage/(.*)$ https://YOUR_PROJECT_ID.supabase.co/storage/$1 [P,L]
ProxyPassReverse /storage/ https://YOUR_PROJECT_ID.supabase.co/storage/
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteCond %{REQUEST_URI} ^/realtime/(.*)$
RewriteRule ^/realtime/(.*)$ wss://YOUR_PROJECT_ID.supabase.co/realtime/$1 [P,L]
ProxyPass / https://YOUR_PROJECT_ID.supabase.co/functions/v1/
ProxyPassReverse / https://YOUR_PROJECT_ID.supabase.co/functions/v1/
SSLCertificateFile /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem
</VirtualHost>- Test preflight directly with
curlor Postman before trusting the browser console;a CORS error almost never means what it says - A
404/5xxfrom upstream has no CORS headers by default, and browsers report that as a CORS failure. Always rule out routing first - Double-check the file that's actually deployed matches what you're staring at locally
Access-Control-Allow-Origin: *is fine here since this proxy never sends credentials/cookies β if you add credentialed requests later, you'll need to echo the realOrigininstead of a wildcard