{
"$type": "site.standard.document",
"description": "CORS errors usually mean the browser is enforcing the API contract your server declared. Fix the contract instead of turning the check off.",
"path": "/stop-fixing-cors-by-disabling-cors/",
"publishedAt": "2026-06-25T07:07:00.000Z",
"site": "at://did:plc:bryys25pc2fnagnyxqgsglhd/site.standard.publication/3mn26bjkkmh23",
"tags": [
"Web",
"Techniques"
],
"textContent": "CORS errors make people do strange things.\n\nThe 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.\n\nFix it by making the browser, frontend, and backend agree on the request.\n\nTHE MENTAL MODEL\n\nCORS is a browser-enforced HTTP contract.\n\nYour frontend says:\n\nOrigin: https://app.example.com\n\nYour backend says:\n\nAccess-Control-Allow-Origin: https://app.example.com\n\nIf those line up, the browser lets your JavaScript read the response. If they do not, the browser blocks your JavaScript from reading it.\n\nCORS 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.\n\nAn origin is not \"the domain\". It is:\n\n * scheme\n * host\n * port\n\nAll of these are different origins:\n\nhttp://localhost:5173\nhttp://localhost:5174\nhttps://localhost:5173\nhttps://app.example.com\nhttps://www.example.com\n\nIf your Vite app runs on http://localhost:5173 and your API allows http://localhost:3000, CORS is working correctly when the browser blocks it.\n\nCHECK THE FAILING REQUEST FIRST\n\nOpen DevTools and click the failing request.\n\nI check these fields before touching server code:\n\n * Request URL: the API URL the browser actually called\n * Request Method: GET, POST, OPTIONS, etc.\n * Origin: the frontend origin the browser sent\n * Request Headers: especially authorization, content-type, and custom x-* headers\n * Response Headers: access-control-allow-origin, access-control-allow-credentials, access-control-allow-methods, access-control-allow-headers\n * Status code: whether the failure is on the real request or the preflight OPTIONS request\n\nDo 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.\n\nREPRODUCE THE PREFLIGHT WITH CURL\n\nWhen 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.\n\nI usually reproduce it like this:\n\ncurl -i -X OPTIONS 'https://api.example.com/projects' \\\n -H 'Origin: https://app.example.com' \\\n -H 'Access-Control-Request-Method: POST' \\\n -H 'Access-Control-Request-Headers: content-type, authorization'\n\nA useful response looks like:\n\nHTTP/2 204\nAccess-Control-Allow-Origin: https://app.example.com\nAccess-Control-Allow-Methods: GET,POST,OPTIONS\nAccess-Control-Allow-Headers: content-type,authorization\nVary: Origin\n\nIf the browser request uses cookies or HTTP auth, it also needs:\n\nAccess-Control-Allow-Credentials: true\n\nAnd the frontend request must opt in:\n\nawait fetch(\"https://api.example.com/projects\", {\n method: \"POST\",\n credentials: \"include\",\n headers: {\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({ name: \"New project\" }),\n});\n\nIf your API uses bearer tokens in the Authorization header, that header still has to be allowed in the preflight:\n\nAccess-Control-Allow-Headers: content-type,authorization\n\nDO NOT MIX WILDCARD ORIGINS WITH CREDENTIALS\n\nThis does not work for credentialed browser requests:\n\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: true\n\nThe 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.\n\nIf cookies are involved, return the exact origin:\n\nAccess-Control-Allow-Origin: https://app.example.com\nAccess-Control-Allow-Credentials: true\nVary: Origin\n\nAlso check your cookie settings. Cross-site cookies usually need:\n\nSet-Cookie: session=...; Path=/; HttpOnly; Secure; SameSite=None\n\nIf 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.\n\nREFLECTING ORIGIN IS FINE ONLY WITH AN ALLOWLIST\n\nThis is the pattern I use:\n\nconst allowedOrigins = new Set([\n \"https://app.example.com\",\n \"https://admin.example.com\",\n \"http://localhost:5173\",\n]);\n\napp.addHook(\"onRequest\", async (request, reply) => {\n const origin = request.headers.origin;\n\n if (origin && allowedOrigins.has(origin)) {\n reply.header(\"Access-Control-Allow-Origin\", origin);\n reply.header(\"Vary\", \"Origin\");\n reply.header(\"Access-Control-Allow-Credentials\", \"true\");\n }\n\n if (request.method === \"OPTIONS\") {\n reply.header(\"Access-Control-Allow-Methods\", \"GET,POST,PUT,DELETE,OPTIONS\");\n reply.header(\"Access-Control-Allow-Headers\", \"content-type,authorization\");\n return reply.status(204).send();\n }\n});\n\nI care more about the allowlist check than the framework code:\n\nallowedOrigins.has(origin)\n\nDo not do this:\n\nreply.header(\"Access-Control-Allow-Origin\", request.headers.origin);\nreply.header(\"Access-Control-Allow-Credentials\", \"true\");\n\nThat 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.\n\nOWASP'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.\n\nIf you use Fastify, @fastify/cors already has the pieces:\n\nimport cors from \"@fastify/cors\";\n\nconst allowedOrigins = new Set([\n \"https://app.example.com\",\n \"https://admin.example.com\",\n \"http://localhost:5173\",\n]);\n\nawait fastify.register(cors, {\n origin(origin, callback) {\n if (!origin) {\n callback(null, false);\n return;\n }\n\n callback(null, allowedOrigins.has(origin));\n },\n credentials: true,\n methods: [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"],\n allowedHeaders: [\"content-type\", \"authorization\"],\n});\n\nThe 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.\n\nSEPARATE BROWSER ACCESS FROM API ACCESS\n\nI treat these as different checks:\n\n * Can the request reach the API? Check DNS, tunnels, routes, reverse proxies, and HTTP status.\n * Can the browser read the response? Check CORS response headers.\n * Is the user allowed to do this? Check sessions, tokens, CSRF, roles, and billing.\n\nDebug them separately.\n\nIf curl works but the browser fails, that usually means the API is reachable and the CORS contract is wrong.\n\nIf the preflight fails with 404, 405, or a redirect, your server or proxy probably is not handling OPTIONS on that route.\n\nIf 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.\n\nIf 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.\n\nIf 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.\n\nWHEN * IS FINE\n\nAccess-Control-Allow-Origin: * is fine for public, non-credentialed resources:\n\n * public JSON metadata\n * public images or generated assets\n * open download endpoints\n * unauthenticated demo APIs where the response is meant to be read by any site\n\nIt is not fine as the default for an authenticated app API.\n\nFor SaaS apps, my default is:\n\n * exact allowed origins\n * Vary: Origin when returning the request origin\n * credentials enabled only when I actually use cookies or HTTP auth\n * explicit allowed methods and headers\n * OPTIONS handled before auth middleware rejects it\n * CORS headers included on error responses too\n\nThat fixes the real problem. The browser stops complaining because the server is now saying exactly what it means.\n\nSources 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.",
"title": "Stop Fixing CORS By Disabling CORS"
}