Supabase CORS error in Next.js: the real fix
TL;DR: A CORS error hitting the Supabase Data API (REST/Auth/Storage) is almost always not a CORS problem — it's an undefined NEXT_PUBLIC_SUPABASE_URL, a paused project, or a 401 from missing table grants that drops the CORS header on the real response. Genuine CORS only bites on Edge Functions, which emit no CORS headers by default. Fix that by handling the OPTIONS preflight first and echoing CORS headers on every response path. Do not paper over it with a wildcard Access-Control-Allow-Origin: '*' on an authenticated endpoint.
Why am I getting a Supabase CORS error in Next.js?
Almost always because it isn't really a CORS error. The browser reports a pile of unrelated failures with the same string — No 'Access-Control-Allow-Origin' header is present on the requested resource — so against your Supabase REST/Auth/Storage endpoints that message is a symptom, not the disease.
The hosted Data API sits behind the Kong API gateway, which attaches CORS headers automatically (historically a wildcard). Those endpoints are built to be called from browsers. Cross-origin fetch from localhost:3000 or your production domain normally just works — no CORS config required. When it doesn't, check these three things before you touch anything CORS-related:
-
Undefined env var. This is the number-one false-CORS cause in Next.js. Client-side env vars must be prefixed
NEXT_PUBLIC_. IfNEXT_PUBLIC_SUPABASE_URLisundefined, the request goes to a bad or relative URL, the fetch dies at the network layer, and the browser labels it "CORS." Log the value or check the Network tab for the actual request host. -
Project paused or a typo'd host. A free-tier project that auto-paused, or a mistyped
*.supabase.cohostname, produces a failed request the browser calls CORS. Unpause it; verify the host character-for-character. -
A 401/403 that strips the CORS header. This is a real pattern:
Access-Control-Allow-Originshows up on theOPTIONSpreflight but is missing on the actualGET/POST— because the request was unauthorized. The usual trigger is missing schema/table GRANTs (often after a Prisma or Drizzle migration wipes theanon/authenticatedgrants), or a missingapikey/Authorizationheader. Fix the auth, not "CORS."
Note the distinction: a wiped GRANT surfaces as a 42501 permission denied for table at the Postgres role level, which is a different failure than an RLS policy rejecting a row. Restore wiped grants like this:
grant usage on schema public to anon, authenticated;
grant select on all tables in schema public to anon, authenticated;
-- and for tables you create later:
alter default privileges in schema public
grant select on tables to anon, authenticated;
Adjust to your security model — don't blanket-grant write access. And if RLS is enabled, make sure policies actually exist, or authorized reads still return nothing. If you're debugging that side of things, our complete Supabase RLS guide walks through the grant-plus-policy interaction.
How do I fix CORS on a Supabase Edge Function?
Return the headers yourself — this is the one genuine CORS case. Edge Functions (Deno, under supabase/functions/...) don't inherit the gateway's CORS behavior and emit no CORS headers by default. Invoke one from the browser — including via supabase.functions.invoke() — and the preflight OPTIONS comes back without Access-Control-Allow-Origin, so the browser blocks it.
The version-agnostic pattern is a shared headers module plus an early OPTIONS handler, with headers echoed on preflight, success, and error paths.
supabase/functions/_shared/cors.ts:
export const corsHeaders = {
'Access-Control-Allow-Origin': '*', // dev only — scope this in prod (below)
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type',
};
supabase/functions/hello/index.ts:
import { corsHeaders } from '../_shared/cors.ts';
Deno.serve(async (req) => {
// Preflight MUST be handled first, before any logic that could throw.
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { name } = await req.json();
return new Response(JSON.stringify({ message: `Hello ${name}` }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
});
} catch (err) {
return new Response(JSON.stringify({ error: String(err) }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
});
}
});
Two failure modes to avoid: putting the OPTIONS handler after body parsing (if the function throws first, the preflight never gets headers and everything is blocked), and adding CORS headers only to the success path (error responses need them too, or a 400 masquerades as a CORS error and hides the real status).
The _shared/cors.ts pattern works on any Supabase CLI or Deno version, so reach for it first. While you're in the function, verify the caller too — a public Edge Function that skips the JWT check is a worse problem than a missing header. See securing Supabase Edge Function auth for how to validate the token instead of trusting the request.
Don't fix CORS with a wildcard
When you add CORS to your own endpoints — Edge Functions, self-hosted Kong, or Next.js route handlers — shipping Access-Control-Allow-Origin: '*' to production on an authenticated endpoint is a real vulnerability: any website can invoke your endpoint on a visitor's behalf. Two hard rules:
- The browser forbids
Allow-Origin: *together withAccess-Control-Allow-Credentials: true/credentials: 'include'. A wildcard can't carry cookies anyway. - Scope to an allowlist and reflect only known origins:
const ALLOWED = new Set([
'https://yourapp.com',
'https://staging.yourapp.com',
// 'http://localhost:3000' for local dev only
]);
function corsFor(req: Request) {
const origin = req.headers.get('Origin') ?? '';
const allow = ALLOWED.has(origin) ? origin : '';
return {
'Access-Control-Allow-Origin': allow, // reflect only allowlisted origins
'Vary': 'Origin', // required when reflecting
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
// add Access-Control-Allow-Credentials: 'true' ONLY with a specific origin
};
}
Vary: Origin keeps a cache from serving one origin's CORS response to another. The wildcard-on-your-own-route trap is common enough that we cover it in depth in why a wildcard CORS policy in Next.js is dangerous — read that before you loosen anything.
- Warningapp/api/data/route.ts:10
Permissive CORS configuration
Reflect a specific allowlist of trusted origins instead of '*', especially on routes that read cookies or Authorization headers.
To be clear about scope: GuardLayer is a static scanner. It reads your source and flags a route handler that hardcodes Access-Control-Allow-Origin: '*' (the nextjs/cors-wildcard rule, shown above). What it cannot see is runtime config — an undefined env var at request time, a paused project, missing database grants, or CORS headers injected by Kong or a CDN. Those are the causes behind most "Supabase CORS errors," and no static scan can catch them. The scanner's job here is narrow: stop a dangerous wildcard from shipping in your own code. For the broader picture of what belongs in code versus config, see Next.js app-layer security.
FAQ
Does the Supabase Data API need CORS configuration? For hosted projects, generally no. The gateway attaches CORS headers and the endpoints are meant to be called from browsers. If you're seeing an error, look at env vars, project state, and grants first.
Why is the CORS header on OPTIONS but not on GET?
That's a 401/403 in disguise. The preflight succeeds, but the authenticated request fails — usually missing anon/authenticated grants or a missing apikey/Authorization header — and the error response drops the CORS header. Restore grants and fix auth.
Should I use a CORS proxy like corsproxy.io to fix it?
No. A proxy masks the real bug (bad URL, missing grants, a 401) and routes your anon key and data through a third party. Fix the root cause.
Why does my Edge Function still fail after adding CORS headers?
Almost always because OPTIONS is handled after code that throws, or the headers are only on the success path. Handle the preflight first, and spread corsHeaders onto error responses too.
Can I use a wildcard origin with credentials?
No — the browser blocks Allow-Origin: * combined with credentialed requests. Reflect a specific allowlisted origin instead.
Catch this before it ships — free
GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.