Stop Fixing CORS By Disabling CORS
CORS errors make people do strange things.
The usual fix I see is some version of “disable CORS”, “allow all origins”, or “install a browser extension”. That might get the request through in development, but it also hides the actual problem: the browser asked your server whether this frontend is allowed to read the response, and your server answered badly.
Fix it by making the browser, frontend, and backend agree on the request.
The mental model
CORS is a browser-enforced HTTP contract.
Your frontend says:
Origin: https://app.example.com
Your backend says:
Access-Control-Allow-Origin: https://app.example.com
If those line up, the browser lets your JavaScript read the response. If they do not, the browser blocks your JavaScript from reading it.
CORS controls browser access to responses. API authentication is a separate layer. It does not stop curl, another backend, a CLI script, or someone calling your API directly. It controls whether browser JavaScript from another origin can read the response. MDN’s CORS guide puts it in the same terms: browsers restrict script-initiated cross-origin requests unless the response includes the right CORS headers.
An origin is not “the domain”. It is:
- scheme
- host
- port
All of these are different origins:
http://localhost:5173
http://localhost:5174
https://localhost:5173
https://app.example.com
https://www.example.com
If your Vite app runs on http://localhost:5173 and your API allows http://localhost:3000, CORS is working correctly when the browser blocks it.
Check the failing request first
Open DevTools and click the failing request.
I check these fields before touching server code:
- Request URL: the API URL the browser actually called
- Request Method:
GET,POST,OPTIONS, etc. - Origin: the frontend origin the browser sent
- Request Headers: especially
authorization,content-type, and customx-*headers - Response Headers:
access-control-allow-origin,access-control-allow-credentials,access-control-allow-methods,access-control-allow-headers - Status code: whether the failure is on the real request or the preflight
OPTIONSrequest
Do not debug CORS from the console message alone. The console usually tells you the symptom. The Network tab tells you what the browser sent and what the server answered.
Reproduce the preflight with curl
When the browser sends a non-simple request, it may send an OPTIONS preflight first. The preflight asks the server whether the real request is allowed.
I usually reproduce it like this:
curl -i -X OPTIONS 'https://api.example.com/projects' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type, authorization'
A useful response looks like:
HTTP/2 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: content-type,authorization
Vary: Origin
If the browser request uses cookies or HTTP auth, it also needs:
Access-Control-Allow-Credentials: true
And the frontend request must opt in:
await fetch("https://api.example.com/projects", {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ name: "New project" }),
});
If your API uses bearer tokens in the Authorization header, that header still has to be allowed in the preflight:
Access-Control-Allow-Headers: content-type,authorization
Do not mix wildcard origins with credentials
This does not work for credentialed browser requests:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
The Fetch standard is explicit here: when the request’s credentials mode is include, Access-Control-Allow-Origin cannot be *. MDN says the same thing for Access-Control-Allow-Origin: wildcard is for requests without credentials.
If cookies are involved, return the exact origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Also check your cookie settings. Cross-site cookies usually need:
Set-Cookie: session=...; Path=/; HttpOnly; Secure; SameSite=None
If you are testing on plain http://localhost, Secure cookies are another thing to check. The browser may reject or omit the cookie before your API code is even involved.
Reflecting Origin is fine only with an allowlist
This is the pattern I use:
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"http://localhost:5173",
]);
app.addHook("onRequest", async (request, reply) => {
const origin = request.headers.origin;
if (origin && allowedOrigins.has(origin)) {
reply.header("Access-Control-Allow-Origin", origin);
reply.header("Vary", "Origin");
reply.header("Access-Control-Allow-Credentials", "true");
}
if (request.method === "OPTIONS") {
reply.header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
reply.header("Access-Control-Allow-Headers", "content-type,authorization");
return reply.status(204).send();
}
});
I care more about the allowlist check than the framework code:
allowedOrigins.has(origin)
Do not do this:
reply.header("Access-Control-Allow-Origin", request.headers.origin);
reply.header("Access-Control-Allow-Credentials", "true");
That reflects any website that asks. If your API uses cookies, you just told the browser that any origin can read credentialed responses from your API.
OWASP’s CORS testing guide describes the same thing from the security side: the browser sends Origin, and the server uses CORS headers to decide whether that cross-origin request is allowed. Make an actual allow/deny decision before reflecting the origin.
If you use Fastify, @fastify/cors already has the pieces:
import cors from "@fastify/cors";
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"http://localhost:5173",
]);
await fastify.register(cors, {
origin(origin, callback) {
if (!origin) {
callback(null, false);
return;
}
callback(null, allowedOrigins.has(origin));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["content-type", "authorization"],
});
The Fastify CORS plugin supports booleans, strings, regexes, arrays, and custom functions for origin. I prefer the function for production apps because it makes the allowlist explicit and easy to log.
Separate browser access from API access
I treat these as different checks:
- Can the request reach the API? Check DNS, tunnels, routes, reverse proxies, and HTTP status.
- Can the browser read the response? Check CORS response headers.
- Is the user allowed to do this? Check sessions, tokens, CSRF, roles, and billing.
Debug them separately.
If curl works but the browser fails, that usually means the API is reachable and the CORS contract is wrong.
If the preflight fails with 404, 405, or a 3xx response, your server or proxy probably is not handling OPTIONS on that route.
If the preflight succeeds but the real request fails, compare the real response headers. I have seen APIs add CORS headers to 204 preflight responses but not to 401, 403, or 500 responses. The browser still needs CORS headers on the response it is trying to expose.
If the request succeeds without cookies but fails after adding credentials: "include", check the exact Access-Control-Allow-Origin value first. A wildcard origin stops being valid for that request. After that, check whether Access-Control-Allow-Credentials: true is present and whether the browser sent the cookie.
If your local frontend is calling your local backend through public dev domains, use the actual dev domain origin in the allowlist. For local webhook and callback testing, I prefer my Cloudflare Tunnel setup because it keeps the browser origin close to production.
When * is fine
Access-Control-Allow-Origin: * is fine for public, non-credentialed resources:
- public JSON metadata
- public images or generated assets
- open download endpoints
- unauthenticated demo APIs where the response is meant to be read by any site
It is not fine as the default for an authenticated app API.
For SaaS apps, my default is:
- exact allowed origins
Vary: Originwhen returning the request origin- credentials enabled only when I use cookies or HTTP auth
- explicit allowed methods and headers
OPTIONShandled before auth middleware rejects it- CORS headers included on error responses too
That fixes the real problem. The browser stops complaining because the server is now saying exactly what it means.
Sources I checked while writing this: MDN’s CORS guide, MDN on Access-Control-Allow-Origin, the WHATWG Fetch standard’s CORS credential rules, OWASP’s CORS testing guide, and the @fastify/cors options.