[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-weekly-cors-explained":32,"quiz-article-weekly-cors-explained":48},[4,20],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Build. Learn. Ship.","Practical courses, engineering-grade articles and open-source tools for people who ship.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Structured paths, listening drills and spaced repetition that actually sticks.","amber-400","⌘",[13,15,29],"es",{"courses":17,"paths":17,"articles":18,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":17},2,{"id":33,"slug":34,"kind":35,"title":36,"description":37,"config":38,"verticalId":5,"vertical":43,"course":40,"_count":44,"access":45,"attempts":47,"questionCount":39},"019fe776-ae7a-739b-933e-671ec814bf30","weekly-cors-explained","PRACTICE_QUIZ","CORS: test yourself","Eight questions on the same-origin policy, simple vs. preflighted requests, credentials, and the header combinations that actually work.",{"questionCount":39,"timeLimitSec":40,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":41,"maxAttempts":40,"revealAnswers":42,"allowFlagging":18,"allowBacktracking":17},8,null,70,"IMMEDIATE",{"slug":6,"name":7},{"questions":39},{"allowed":17,"reason":46},"FREE",[],{"id":49,"slug":34,"title":50,"subtitle":40,"excerpt":51,"coverUrl":52,"locale":13,"readingMinutes":53,"publishedAt":54,"viewCount":55,"likeCount":19,"commentCount":19,"author":56,"vertical":61,"topic":62,"tags":65,"_count":76,"playground":78,"body":80,"bodyMd":449,"seo":450,"translationGroupId":452,"thread":453,"assessments":455,"translations":458,"quiz":460},"019fe660-cf2a-71df-b517-8d722f92cc37","CORS Explained: The Complete Guide (with Cheat Sheet)","A complete guide to CORS: the same-origin policy, preflight requests, and Access-Control-Allow-Origin headers — with worked examples and a cheat sheet.","\u002Fmedia\u002Fcovers\u002Fweekly-cors-explained.png",14,"2026-08-03T09:54:26.268Z",171,{"id":57,"name":58,"username":59,"avatarUrl":40,"headline":60},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":63,"name":64},"security","Security",[66,69,70,73],{"slug":67,"name":68,"color":40},"webdev","Webdev",{"slug":63,"name":64,"color":40},{"slug":71,"name":72,"color":40},"javascript","Javascript",{"slug":74,"name":75,"color":40},"tutorial","Tutorial",{"assessments":77},1,{"slug":34,"title":79},"CORS request simulator — interactive playground",{"blocks":81,"version":77},[82,86,91,94,103,106,109,123,126,129,135,138,141,144,147,150,157,160,163,166,188,191,194,197,203,206,210,213,217,220,223,228,231,235,238,242,245,248,251,254,257,260,263,266,270,273,277,280,283,286,289,292,295,298,301,304,307,310,313,316,319,322,325,329,332,336,339,342,345,349,352,355,358,361,364,367,370,373,376,379,382,417,421,424,432,435,438,441],{"id":83,"html":84,"type":85},"b1","\u003Cp>Your \u003Ccode>fetch\u003C\u002Fcode> call is correct. The server received it, ran the handler, and sent back a \u003Ccode>200\u003C\u002Fcode> with a body. Your JavaScript never sees any of that — instead the console prints a red line about \u003Ccode>Access-Control-Allow-Origin\u003C\u002Fcode>, and the promise rejects with a \u003Ccode>TypeError: Failed to fetch\u003C\u002Fcode> that tells you nothing about what actually went wrong. You didn&#39;t hit a bug in your code. You hit CORS, and the fix is one line — once you know which line.\u003C\u002Fp>","paragraph",{"id":87,"html":88,"text":89,"type":90,"level":31},"b2","What you&#39;ll learn","What you'll learn","heading",{"id":92,"html":93,"type":85},"b3","\u003Cp>By the end of this guide you&#39;ll be able to:\u003C\u002Fp>",{"id":95,"type":96,"items":97,"ordered":18},"b4","list",[98,99,100,101,102],"Explain, precisely, what the browser is and isn&#39;t protecting when it enforces CORS","Tell whether a given \u003Ccode>fetch\u003C\u002Fcode> will be sent as a &quot;simple&quot; request or trigger a preflight \u003Ccode>OPTIONS\u003C\u002Fcode> round trip, before you open dev tools","Read a CORS console error and map it to the exact response header that&#39;s missing or wrong","Configure a server (with or without a framework) to allow the right origins, methods, and credentials — and only those","Avoid the two mistakes that cause the most CORS incidents: an unscoped wildcard, and combining \u003Ccode>*\u003C\u002Fcode> with credentials",{"id":104,"html":105,"type":85},"b5","\u003Cp>\u003Cstrong>Who this is for:\u003C\u002Fstrong> you&#39;ve written a \u003Ccode>fetch\u003C\u002Fcode> or \u003Ccode>axios\u003C\u002Fcode> call to an API on a different host and hit an error you fixed by copy-pasting a header without fully understanding why it worked.\u003C\u002Fp>",{"id":107,"html":108,"text":108,"type":90,"level":31},"b6","Contents",{"id":110,"type":96,"items":111,"ordered":18},"b7",[112,113,114,115,116,117,118,119,120,121,122],"\u003Ca href=\"#why-cors-exists\">Why CORS exists\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-cors-is-the-browser-enforcing-a-promise-the-server-makes\">The mental model\u003C\u002Fa>","\u003Ca href=\"#stage-1-what-makes-two-urls-the-same-origin\">Stage 1: what makes two URLs the same origin\u003C\u002Fa>","\u003Ca href=\"#stage-2-simple-requests-vs-preflighted-requests\">Stage 2: simple requests vs. preflighted requests\u003C\u002Fa>","\u003Ca href=\"#stage-3-reading-and-answering-a-preflight\">Stage 3: reading and answering a preflight\u003C\u002Fa>","\u003Ca href=\"#stage-4-credentials-cookies-and-the-wildcard-trap\">Stage 4: credentials, cookies, and the wildcard trap\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":124,"html":125,"text":125,"type":90,"level":31},"b8","Why CORS exists",{"id":127,"html":128,"type":85},"b9","\u003Cp>Here&#39;s the naive expectation: if a server responds to a request, the calling JavaScript should be able to read the response. That&#39;s how requests to your own domain behave, so it&#39;s a reasonable thing to assume — until you point \u003Ccode>fetch\u003C\u002Fcode> at a different origin.\u003C\u002Fp>",{"id":130,"code":131,"type":132,"language":133,"highlight":134},"b10","\u002F\u002F running on https:\u002F\u002Fapp.example.com\nfetch(\"https:\u002F\u002Fapi.other-service.com\u002Fprofile\")\n  .then((res) => res.json())\n  .then((data) => console.log(data))\n  .catch((err) => console.error(err));\n\n\u002F\u002F Console:\n\u002F\u002F Access to fetch at 'https:\u002F\u002Fapi.other-service.com\u002Fprofile' from origin\n\u002F\u002F 'https:\u002F\u002Fapp.example.com' has been blocked by CORS policy: No\n\u002F\u002F 'Access-Control-Allow-Origin' header is present on the requested resource.","code","js",[],{"id":136,"html":137,"type":85},"b11","\u003Cp>Open the Network tab while this runs and the request often shows a \u003Ccode>200\u003C\u002Fcode> with a full JSON body sitting right there. The server did its job. The browser is the one refusing to hand that body to your script, and it does this on purpose: without this check, any site you happen to have open in a tab could quietly issue authenticated requests to your bank, your email provider, or your company&#39;s internal API — using cookies your browser is already sending for you — and read the responses. This is the \u003Cstrong>same-origin policy\u003C\u002Fstrong>, and it has protected the web since the mid-1990s. CORS (Cross-Origin Resource Sharing) is not a separate restriction bolted on top of it — it&#39;s the mechanism a server uses to selectively \u003Cem>relax\u003C\u002Fem> that policy for origins it trusts.\u003C\u002Fp>",{"id":139,"html":140,"text":140,"type":90,"level":31},"b12","The mental model: CORS is the browser enforcing a promise the server makes",{"id":142,"html":143,"type":85},"b13","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> CORS does not stop your request from reaching the server. It stops your \u003Cem>JavaScript\u003C\u002Fem> from reading a cross-origin \u003Cem>response\u003C\u002Fem>, unless the server&#39;s response headers explicitly say that origin is allowed to see it. For a large class of requests, the request is sent, the server executes it, and only the read-back is blocked.\u003C\u002Fp>",{"id":145,"html":146,"type":85},"b14","\u003Cp>That last sentence is the part almost everyone gets backwards, and it has a real consequence: if your cross-origin request is a \u003Ccode>POST\u003C\u002Fcode> that writes to a database, and the response is missing the right CORS header, \u003Cstrong>the write still happened\u003C\u002Fstrong>. Your JavaScript just never finds out it succeeded. A &quot;CORS error&quot; in the console is not evidence that nothing happened server-side — it&#39;s evidence that the browser hid the outcome from you.\u003C\u002Fp>",{"id":148,"html":149,"type":85},"b15","\u003Cp>Picture it as a two-party handshake with the browser standing in the middle:\u003C\u002Fp>",{"id":151,"type":96,"items":152,"ordered":17},"b16",[153,154,155,156],"Your page, on origin A, asks the browser to fetch a resource on origin B.","The browser sends the request (sometimes after first asking permission — more on that below).","Origin B&#39;s response comes back with headers like \u003Ccode>Access-Control-Allow-Origin: https:\u002F\u002Fapp.example.com\u003C\u002Fcode>.","The browser checks that header against the page&#39;s own origin. Match → your JavaScript gets the response. No match, or no header at all → the browser throws the response away and your \u003Ccode>fetch\u003C\u002Fcode> promise rejects.",{"id":158,"html":159,"type":85},"b17","\u003Cp>Origin B never has to know your page exists in advance. It just has to say, in every response, which origins it&#39;s willing to let read that response. That&#39;s the entire protocol.\u003C\u002Fp>",{"id":161,"html":162,"text":162,"type":90,"level":31},"b18","Stage 1: what makes two URLs the same origin",{"id":164,"html":165,"type":85},"b19","\u003Cp>An \u003Cstrong>origin\u003C\u002Fstrong> is the triple of \u003Cstrong>scheme + host + port\u003C\u002Fstrong>. Change any one of the three and you have a different origin, even if the URLs look nearly identical:\u003C\u002Fp>",{"id":167,"head":168,"rows":172,"type":187},"b20",[169,170,171],"URL","Same origin as \u003Ccode>https:\u002F\u002Fapp.example.com:443\u003C\u002Fcode>?","Why",[173,177,181,184],[174,175,176],"\u003Ccode>https:\u002F\u002Fapp.example.com\u002Fsettings\u003C\u002Fcode>","✅ Yes","Different path, same scheme\u002Fhost\u002Fport",[178,179,180],"\u003Ccode>http:\u002F\u002Fapp.example.com\u003C\u002Fcode>","❌ No","Different scheme (\u003Ccode>http\u003C\u002Fcode> vs \u003Ccode>https\u003C\u002Fcode>)",[182,179,183],"\u003Ccode>https:\u002F\u002Fapi.example.com\u003C\u002Fcode>","Different host (subdomain counts)",[185,179,186],"\u003Ccode>https:\u002F\u002Fapp.example.com:8443\u003C\u002Fcode>","Different port","table",{"id":189,"html":190,"type":85},"b21","\u003Cp>This trips people up locally more than in production: \u003Ccode>http:\u002F\u002Flocalhost:3000\u003C\u002Fcode> (your frontend dev server) and \u003Ccode>http:\u002F\u002Flocalhost:8080\u003C\u002Fcode> (your backend) are \u003Cstrong>different origins\u003C\u002Fstrong>, because the port differs. That &quot;works on my machine, breaks in the console&quot; moment during local development is usually this, not a real production concern — though you still need the same CORS configuration in dev as you&#39;ll need once the frontend and API genuinely live on different hosts. If you parse or compare \u003Ccode>origin\u003C\u002Fcode> values yourself rather than trusting \u003Ccode>req.headers.origin\u003C\u002Fcode> blindly, \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fthe-url-is-your-best-state-manager-5hl8\">parsing URLs safely\u003C\u002Fa> is worth getting right — a malformed or attacker-controlled origin string is not something you want to string-match casually.\u003C\u002Fp>",{"id":192,"html":193,"text":193,"type":90,"level":31},"b22","Stage 2: simple requests vs. preflighted requests",{"id":195,"html":196,"type":85},"b23","\u003Cp>Not every cross-origin request behaves the same way. The \u003Ca href=\"https:\u002F\u002Ffetch.spec.whatwg.org\u002F#cors-safelisted-method\">Fetch specification\u003C\u002Fa> defines a \u003Cstrong>&quot;simple request&quot;\u003C\u002Fstrong>: one the browser sends directly, with no extra round trip, because it matches a narrow safelist:\u003C\u002Fp>",{"id":198,"type":96,"items":199,"ordered":18},"b24",[200,201,202],"Method is \u003Ccode>GET\u003C\u002Fcode>, \u003Ccode>HEAD\u003C\u002Fcode>, or \u003Ccode>POST\u003C\u002Fcode>.","The only headers you set by hand are ones on the CORS-safelisted list — \u003Ccode>Accept\u003C\u002Fcode>, \u003Ccode>Accept-Language\u003C\u002Fcode>, \u003Ccode>Content-Language\u003C\u002Fcode>, or \u003Ccode>Content-Type\u003C\u002Fcode> (see below).","If \u003Ccode>Content-Type\u003C\u002Fcode> is set, its value is one of exactly three: \u003Ccode>text\u002Fplain\u003C\u002Fcode>, \u003Ccode>multipart\u002Fform-data\u003C\u002Fcode>, or \u003Ccode>application\u002Fx-www-form-urlencoded\u003C\u002Fcode>.",{"id":204,"html":205,"type":85},"b25","\u003Cp>Notice what&#39;s missing from that list: \u003Ccode>application\u002Fjson\u003C\u002Fcode>. This is the single most common surprise in CORS debugging. A request that looks simple —\u003C\u002Fp>",{"id":207,"code":208,"type":132,"language":133,"highlight":209},"b26","fetch(\"https:\u002F\u002Fapi.example.com\u002Forders\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application\u002Fjson\" },\n  body: JSON.stringify({ item: \"widget\" }),\n});",[],{"id":211,"html":212,"type":85},"b27","\u003Cp>— does \u003Cstrong>not\u003C\u002Fstrong> qualify, because \u003Ccode>application\u002Fjson\u003C\u002Fcode> isn&#39;t one of the three safelisted content types. The browser has to find out, \u003Cem>before\u003C\u002Fem> sending your \u003Ccode>POST\u003C\u002Fcode>, whether the server accepts it. So it sends a \u003Cstrong>preflight\u003C\u002Fstrong>: a separate \u003Ccode>OPTIONS\u003C\u002Fcode> request, automatically, that your code never sees or triggers directly.\u003C\u002Fp>",{"id":214,"html":215,"type":216},"b28","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> the preflight isn&#39;t extra ceremony the spec invented to slow you down — it exists because a \u003Ccode>POST\u003C\u002Fcode> with an arbitrary JSON body and custom headers is exactly the shape of request that could carry side effects (write a row, charge a card), so the browser confirms permission \u003Cem>before\u003C\u002Fem> letting that request out the door, rather than after the fact like it does for simple requests.\u003C\u002Fp>\n","quote",{"id":218,"html":219,"text":219,"type":90,"level":31},"b29","Stage 3: reading and answering a preflight",{"id":221,"html":222,"type":85},"b30","\u003Cp>Open the Network tab on the JSON \u003Ccode>POST\u003C\u002Fcode> above and you&#39;ll see two requests, not one. First, the browser-generated preflight:\u003C\u002Fp>",{"id":224,"code":225,"type":132,"language":226,"highlight":227},"b31","OPTIONS \u002Forders HTTP\u002F1.1\nOrigin: https:\u002F\u002Fapp.example.com\nAccess-Control-Request-Method: POST\nAccess-Control-Request-Headers: content-type","http",[],{"id":229,"html":230,"type":85},"b32","\u003Cp>The server must answer with headers that explicitly cover what was asked:\u003C\u002Fp>",{"id":232,"code":233,"type":132,"language":226,"highlight":234},"b33","HTTP\u002F1.1 204 No Content\nAccess-Control-Allow-Origin: https:\u002F\u002Fapp.example.com\nAccess-Control-Allow-Methods: POST, GET, OPTIONS\nAccess-Control-Allow-Headers: Content-Type\nAccess-Control-Max-Age: 600",[],{"id":236,"html":237,"type":85},"b34","\u003Cp>Only if every requested method and header is covered does the browser send the real \u003Ccode>POST\u003C\u002Fcode>. Here&#39;s the same thing wired up with Express, which is the shape you&#39;ll write by hand before reaching for a library:\u003C\u002Fp>",{"id":239,"code":240,"type":132,"language":133,"highlight":241},"b35","import express from \"express\";\nconst app = express();\n\napp.use((req, res, next) => {\n  const allowedOrigin = \"https:\u002F\u002Fapp.example.com\";\n  res.setHeader(\"Access-Control-Allow-Origin\", allowedOrigin);\n  res.setHeader(\"Vary\", \"Origin\"); \u002F\u002F see \"Edge cases\" — required when reflecting one origin\n  res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\");\n  res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type\");\n  res.setHeader(\"Access-Control-Max-Age\", \"600\"); \u002F\u002F cache the preflight result (seconds)\n\n  if (req.method === \"OPTIONS\") {\n    return res.sendStatus(204); \u002F\u002F preflight gets no body, just the headers above\n  }\n  next();\n});\n\napp.post(\"\u002Forders\", express.json(), (req, res) => {\n  res.json({ ok: true, item: req.body.item });\n});",[],{"id":243,"html":244,"type":85},"b36","\u003C!-- playground:start -->",{"id":246,"html":247,"text":247,"type":90,"level":31},"b37","🎮 Try it yourself",{"id":249,"html":250,"type":85},"b38","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fposts\u002F2026-08-03-weekly-cors-explained\u002Fplayground\u002F\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":252,"html":253,"type":85},"b39","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":255,"html":256,"type":85},"b40","\u003C!-- playground:end -->",{"id":258,"html":259,"type":85},"b41","\u003Cp>Almost nobody hand-writes this in production — the \u003Ca href=\"https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002Fcors\">\u003Ccode>cors\u003C\u002Fcode>\u003C\u002Fa> npm package does exactly this with a friendlier config surface — but understanding this middleware means you can read what any library is doing under the hood, and debug it when it isn&#39;t doing what you expect.\u003C\u002Fp>",{"id":261,"html":262,"text":262,"type":90,"level":31},"b42","Stage 4: credentials, cookies, and the wildcard trap",{"id":264,"html":265,"type":85},"b43","\u003Cp>By default, \u003Ccode>fetch\u003C\u002Fcode> does not send cookies on a cross-origin request. If your API relies on a session cookie, you opt in explicitly:\u003C\u002Fp>",{"id":267,"code":268,"type":132,"language":133,"highlight":269},"b44","fetch(\"https:\u002F\u002Fapi.example.com\u002Fme\", { credentials: \"include\" });",[],{"id":271,"html":272,"type":85},"b45","\u003Cp>The moment you do this, two things become mandatory on the server side, and getting either wrong produces a CORS error that looks identical to the plain missing-header case:\u003C\u002Fp>",{"id":274,"code":275,"type":132,"language":133,"highlight":276},"b46","res.setHeader(\"Access-Control-Allow-Origin\", \"https:\u002F\u002Fapp.example.com\"); \u002F\u002F NOT \"*\"\nres.setHeader(\"Access-Control-Allow-Credentials\", \"true\");",[],{"id":278,"html":279,"type":85},"b47","\u003Cp>\u003Ccode>Access-Control-Allow-Origin: *\u003C\u002Fcode> and credentials are mutually exclusive by design in the CORS spec — a wildcard means &quot;any origin may read this,&quot; and combined with cookies that would mean any site on the internet could read a logged-in user&#39;s data through your API. Browsers refuse to honor \u003Ccode>*\u003C\u002Fcode> alongside \u003Ccode>Access-Control-Allow-Credentials: true\u003C\u002Fcode>; you must name the exact origin. This is the second most common CORS incident after the \u003Ccode>Content-Type\u003C\u002Fcode> preflight surprise: someone sets \u003Ccode>*\u003C\u002Fcode> to &quot;just make CORS work,&quot; it does — until credentials get added later, and it silently stops working with no code change on the client at all.\u003C\u002Fp>",{"id":281,"html":282,"text":282,"type":90,"level":31},"b48","Edge cases and gotchas",{"id":284,"html":285,"type":85},"b49","\u003Cp>\u003Cstrong>CORS is enforced by the browser, not the server.\u003C\u002Fstrong> A request from \u003Ccode>curl\u003C\u002Fcode>, Postman, a mobile app, or another backend service ignores CORS headers entirely — there&#39;s no JavaScript execution context for the browser to protect. &quot;It works in Postman but not in my web app&quot; is not a contradiction; it&#39;s the expected outcome, because Postman was never subject to this check in the first place.\u003C\u002Fp>",{"id":287,"html":288,"type":85},"b50","\u003Cp>\u003Cstrong>Reflecting an origin requires \u003Ccode>Vary: Origin\u003C\u002Fcode>.\u003C\u002Fstrong> If your server supports multiple allowed origins by checking the incoming \u003Ccode>Origin\u003C\u002Fcode> header against an allowlist and echoing it back, you must also send \u003Ccode>Vary: Origin\u003C\u002Fcode>. Without it, a shared cache (a CDN, or even the browser&#39;s own HTTP cache) can serve a response with origin A&#39;s \u003Ccode>Access-Control-Allow-Origin\u003C\u002Fcode> value to a request from origin B, silently breaking CORS for one origin while it looks fine for the other.\u003C\u002Fp>",{"id":290,"html":291,"type":85},"b51","\u003Cp>\u003Cstrong>\u003Ccode>Access-Control-Max-Age\u003C\u002Fcode> has a browser-enforced ceiling.\u003C\u002Fstrong> You can ask the browser to cache a preflight result for a long time, but browsers cap how long they&#39;ll actually honor it regardless of the value you send (Chromium currently caps it well under 24 hours). Don&#39;t assume a large number eliminates preflight traffic entirely — measure it.\u003C\u002Fp>",{"id":293,"html":294,"type":85},"b52","\u003Cp>\u003Cstrong>Redirects are re-checked.\u003C\u002Fstrong> If a cross-origin response is a redirect to another cross-origin URL, the CORS check applies again at the final destination. A misconfigured redirect target produces a CORS failure that looks like it&#39;s coming from the URL you called, when the actual problem is the URL you were redirected to.\u003C\u002Fp>",{"id":296,"html":297,"type":85},"b53","\u003Cp>\u003Cstrong>\u003Ccode>mode: &quot;no-cors&quot;\u003C\u002Fcode> doesn&#39;t bypass CORS — it neuters the response.\u003C\u002Fstrong> Setting \u003Ccode>fetch(url, { mode: &quot;no-cors&quot; })\u003C\u002Fcode> lets the request go out without a CORS check, but the response comes back &quot;opaque&quot;: your code can&#39;t read its status, headers, or body. It&#39;s useful for fire-and-forget beacons, not for anything you need data back from.\u003C\u002Fp>",{"id":299,"html":300,"type":85},"b54","\u003Cp>\u003Cstrong>Same-origin is not the same as same-site.\u003C\u002Fstrong> \u003Ccode>app.example.com\u003C\u002Fcode> and \u003Ccode>api.example.com\u003C\u002Fcode> are different \u003Cem>origins\u003C\u002Fem> (different host) but the same \u003Cem>site\u003C\u002Fem> (same registrable domain). This distinction matters for cookie \u003Ccode>SameSite\u003C\u002Fcode> behavior, which is a related but separate mechanism from CORS — don&#39;t conflate the two when a cookie isn&#39;t showing up where you expect it.\u003C\u002Fp>",{"id":302,"html":303,"text":303,"type":90,"level":31},"b55","Best practices",{"id":305,"html":306,"type":85},"b56","\u003Cp>\u003Cstrong>Reach for a real allowlist in production.\u003C\u002Fstrong> Keep an array or \u003Ccode>Set\u003C\u002Fcode> of exact allowed origins, check the incoming \u003Ccode>Origin\u003C\u002Fcode> header against it, and reflect only a match — never a bare \u003Ccode>*\u003C\u002Fcode> — once cookies, \u003Ccode>Authorization\u003C\u002Fcode> headers, or any sensitive data are involved.\u003C\u002Fp>",{"id":308,"html":309,"type":85},"b57","\u003Cp>\u003Cstrong>Avoid it when\u003C\u002Fstrong> you&#39;re building a public, read-only, unauthenticated API. If there are no credentials and no sensitive data, \u003Ccode>Access-Control-Allow-Origin: *\u003C\u002Fcode> is legitimate and simpler than maintaining an allowlist — that&#39;s exactly what public CDNs and open data APIs use.\u003C\u002Fp>",{"id":311,"html":312,"type":85},"b58","\u003Cp>\u003Cstrong>Prefer a bearer token over a cookie for cross-origin auth when you can.\u003C\u002Fstrong> An \u003Ccode>Authorization: Bearer &lt;token&gt;\u003C\u002Fcode> header sidesteps the credentials-and-wildcard interaction entirely, because tokens aren&#39;t sent automatically the way cookies are — you attach them explicitly per request, which also sidesteps CSRF concerns that cookie-based auth invites.\u003C\u002Fp>",{"id":314,"html":315,"type":85},"b59","\u003Cp>\u003Cstrong>Set \u003Ccode>Access-Control-Max-Age\u003C\u002Fcode> deliberately on hot endpoints.\u003C\u002Fstrong> A chatty preflight on every request to a high-traffic endpoint is a real, measurable cost; caching it for even a few minutes removes a full round trip from most calls.\u003C\u002Fp>",{"id":317,"html":318,"type":85},"b60","\u003Cp>\u003Cstrong>Don&#39;t try to catch a CORS failure and inspect it in JavaScript.\u003C\u002Fstrong> By design, the browser gives your \u003Ccode>catch\u003C\u002Fcode> block almost no detail — a generic \u003Ccode>TypeError: Failed to fetch\u003C\u002Fcode>, not &quot;missing Access-Control-Allow-Origin.&quot; The Network tab and console are where the real diagnosis happens, not \u003Ccode>error.message\u003C\u002Fcode>. This is also why pairing a cross-origin call with an \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fyour-fetch-is-still-running-after-the-user-left-cmf\">AbortController\u003C\u002Fa> is worth doing regardless of CORS — a request that&#39;s blocked from being \u003Cem>read\u003C\u002Fem> by the browser can still be left running server-side until something cancels it.\u003C\u002Fp>",{"id":320,"html":321,"type":85},"b61","\u003Cp>\u003Cstrong>Firing several cross-origin requests at once has the same CORS rules per-request.\u003C\u002Fstrong> Each origin gets its own preflight and its own header check — there&#39;s no batching. If you&#39;re using \u003Ccode>Promise.allSettled\u003C\u002Fcode> or \u003Ccode>Promise.any\u003C\u002Fcode> to \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fyou-reach-for-promiseall-for-every-concurrent-request-heres-when-to-use-the-other-three-31d6\">run several requests concurrently\u003C\u002Fa>, expect one bad CORS config on a single endpoint to fail only that request, not the whole batch.\u003C\u002Fp>",{"id":323,"html":324,"text":324,"type":90,"level":31},"b62","FAQ",{"id":326,"html":327,"text":327,"type":90,"level":328},"b63","Why does my request work in Postman but fail in the browser?",3,{"id":330,"html":331,"type":85},"b64","\u003Cp>Because CORS is a browser-only protection. Postman, \u003Ccode>curl\u003C\u002Fcode>, and server-to-server calls have no same-origin policy to enforce, so they never send a preflight and never check response headers. Only requests initiated by JavaScript running in a browser page are subject to CORS.\u003C\u002Fp>",{"id":333,"html":334,"text":335,"type":90,"level":328},"b65","Can I just set \u003Ccode>Access-Control-Allow-Origin: *\u003C\u002Fcode> to fix a CORS error?","Can I just set Access-Control-Allow-Origin:  to fix a CORS error?",{"id":337,"html":338,"type":85},"b66","\u003Cp>It fixes the error, but only use it when the endpoint returns no sensitive, credential-gated data. \u003Ccode>*\u003C\u002Fcode> cannot be combined with \u003Ccode>Access-Control-Allow-Credentials: true\u003C\u002Fcode> — browsers reject that combination — so it isn&#39;t an option at all once cookies or auth headers are involved.\u003C\u002Fp>",{"id":340,"html":341,"text":341,"type":90,"level":328},"b67","Does CORS protect my server from unauthorized access?",{"id":343,"html":344,"type":85},"b68","\u003Cp>No. CORS is enforced entirely by the requesting browser; a non-browser client can ignore it completely and hit your API directly. It protects \u003Cem>browser users\u003C\u002Fem> from malicious pages reading data on their behalf — it is not authentication, and it is not a substitute for checking credentials server-side.\u003C\u002Fp>",{"id":346,"html":347,"text":348,"type":90,"level":328},"b69","Why did adding a \u003Ccode>Content-Type: application\u002Fjson\u003C\u002Fcode> header suddenly trigger a preflight?","Why did adding a Content-Type: application\u002Fjson header suddenly trigger a preflight?",{"id":350,"html":351,"type":85},"b70","\u003Cp>Because \u003Ccode>application\u002Fjson\u003C\u002Fcode> isn&#39;t one of the three CORS-safelisted content-type values (\u003Ccode>text\u002Fplain\u003C\u002Fcode>, \u003Ccode>multipart\u002Fform-data\u003C\u002Fcode>, \u003Ccode>application\u002Fx-www-form-urlencoded\u003C\u002Fcode>). Any other \u003Ccode>Content-Type\u003C\u002Fcode>, or any custom header at all, moves the request out of &quot;simple&quot; and into &quot;preflighted.&quot;\u003C\u002Fp>",{"id":353,"html":354,"text":354,"type":90,"level":328},"b71","Can I use CORS with cookies?",{"id":356,"html":357,"type":85},"b72","\u003Cp>Yes: send the request with \u003Ccode>credentials: &quot;include&quot;\u003C\u002Fcode>, and have the server respond with \u003Ccode>Access-Control-Allow-Credentials: true\u003C\u002Fcode> and an exact (non-wildcard) \u003Ccode>Access-Control-Allow-Origin\u003C\u002Fcode>. Both sides must opt in explicitly.\u003C\u002Fp>",{"id":359,"html":360,"text":360,"type":90,"level":328},"b73","Is CORS the same thing as CSRF protection?",{"id":362,"html":363,"type":85},"b74","\u003Cp>No, and this is a common source of false security confidence. CORS controls whether \u003Cem>JavaScript can read a cross-origin response\u003C\u002Fem>; it does nothing to stop a cross-origin \u003Cem>form submission\u003C\u002Fem> or an \u003Ccode>&lt;img&gt;\u003C\u002Fcode>-style GET, which are subject to a different set of rules. CSRF defenses (tokens, \u003Ccode>SameSite\u003C\u002Fcode> cookies) are a separate mechanism you still need.\u003C\u002Fp>",{"id":365,"html":366,"type":85},"b75","\u003C!-- quiz:start -->",{"id":368,"html":369,"text":369,"type":90,"level":31},"b76","🧠 Test yourself",{"id":371,"html":372,"type":85},"b77","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fquiz\u002Ftake.html?post=2026-08-03-weekly-cors-explained\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":374,"html":375,"type":85},"b78","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":377,"html":378,"type":85},"b79","\u003C!-- quiz:end -->",{"id":380,"html":381,"text":381,"type":90,"level":31},"b80","Cheat sheet",{"id":383,"head":384,"rows":388,"type":187},"b81",[385,386,387],"Task","Header \u002F Code","Notes",[389,393,397,401,405,409,413],[390,391,392],"Allow one specific origin","\u003Ccode>Access-Control-Allow-Origin: https:\u002F\u002Fapp.example.com\u003C\u002Fcode>","Required exact match when credentials are used",[394,395,396],"Allow any origin (no credentials)","\u003Ccode>Access-Control-Allow-Origin: *\u003C\u002Fcode>","Never combine with \u003Ccode>Allow-Credentials\u003C\u002Fcode>",[398,399,400],"Support multiple origins","Reflect the request&#39;s \u003Ccode>Origin\u003C\u002Fcode> if it&#39;s in your allowlist","Must also send \u003Ccode>Vary: Origin\u003C\u002Fcode>",[402,403,404],"Allow cookies cross-origin","\u003Ccode>Access-Control-Allow-Credentials: true\u003C\u002Fcode> + \u003Ccode>credentials: &quot;include&quot;\u003C\u002Fcode> on the client","No wildcard allowed",[406,407,408],"Declare allowed methods","\u003Ccode>Access-Control-Allow-Methods: GET, POST, OPTIONS\u003C\u002Fcode>","Checked against the preflight&#39;s requested method",[410,411,412],"Declare allowed headers","\u003Ccode>Access-Control-Allow-Headers: Content-Type, Authorization\u003C\u002Fcode>","Must list every custom header the client sends",[414,415,416],"Cache the preflight","\u003Ccode>Access-Control-Max-Age: 600\u003C\u002Fcode>","Seconds; browsers cap the effective maximum",{"id":418,"code":419,"type":132,"language":133,"highlight":420},"b82","\u002F\u002F The whole pattern, copy-paste ready (Express, hand-rolled — swap for the\n\u002F\u002F `cors` package once you understand what it's automating for you).\nimport express from \"express\";\nconst app = express();\n\nconst ALLOWED_ORIGINS = new Set([\n  \"https:\u002F\u002Fapp.example.com\",\n  \"https:\u002F\u002Fstaging.example.com\",\n]);\n\napp.use((req, res, next) => {\n  const origin = req.headers.origin;\n  if (origin && ALLOWED_ORIGINS.has(origin)) {\n    res.setHeader(\"Access-Control-Allow-Origin\", origin);\n    res.setHeader(\"Vary\", \"Origin\");\n    res.setHeader(\"Access-Control-Allow-Credentials\", \"true\");\n  }\n  res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, PATCH, DELETE, OPTIONS\");\n  res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization\");\n  res.setHeader(\"Access-Control-Max-Age\", \"600\");\n\n  if (req.method === \"OPTIONS\") return res.sendStatus(204);\n  next();\n});",[],{"id":422,"html":423,"text":423,"type":90,"level":31},"b83","Key takeaways",{"id":425,"type":96,"items":426,"ordered":18},"b84",[427,428,429,430,431],"CORS is enforced by the \u003Cstrong>browser\u003C\u002Fstrong>, checking response headers against the calling page&#39;s origin — it does not stop the request from reaching (or executing on) the server.","A request is either \u003Cstrong>simple\u003C\u002Fstrong> (sent directly) or \u003Cstrong>preflighted\u003C\u002Fstrong> (an \u003Ccode>OPTIONS\u003C\u002Fcode> round trip first); a custom header or a JSON \u003Ccode>Content-Type\u003C\u002Fcode> is the most common trigger for the latter.","\u003Ccode>Access-Control-Allow-Origin: *\u003C\u002Fcode> and credentials (\u003Ccode>Access-Control-Allow-Credentials: true\u003C\u002Fcode>) can never be combined — name the exact origin once cookies or tokens are involved.","When reflecting one of several allowed origins, always send \u003Ccode>Vary: Origin\u003C\u002Fcode>, or a shared cache will hand your CORS headers to the wrong caller.","CORS is not authentication and not CSRF protection — it&#39;s a read-access rule for browser JavaScript, layered on top of, not instead of, your server&#39;s own auth checks.",{"id":433,"html":434,"type":85},"b85","\u003Cp>That \u003Ccode>TypeError: Failed to fetch\u003C\u002Fcode> from the top of this article is now legible: check the Network tab, find the missing or mismatched header, and you&#39;ll know within a minute which of the four fixes above it needs. What&#39;s the CORS error that took you the longest to actually understand — not just patch with a wildcard? Tell me in the comments.\u003C\u002Fp>",{"id":436,"type":437},"b86","divider",{"id":439,"html":440,"type":85},"b87","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":442,"type":96,"items":443,"ordered":18},"b88",[444,445,446,447,448],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","💼 \u003Cstrong>LinkedIn\u003C\u002Fstrong> — \u003Ca href=\"https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fparsa-jiravand\u002F\">linkedin.com\u002Fin\u002Fparsa-jiravand\u003C\u002Fa>","✉️ \u003Cstrong>Email\u003C\u002Fstrong> (work &amp; contract inquiries): \u003Ca href=\"mailto:bestpractice2026@gmail.com\">bestpractice2026@gmail.com\u003C\u002Fa>","Your `fetch` call is correct. The server received it, ran the handler, and sent back a `200` with a body. Your JavaScript never sees any of that — instead the console prints a red line about `Access-Control-Allow-Origin`, and the promise rejects with a `TypeError: Failed to fetch` that tells you nothing about what actually went wrong. You didn't hit a bug in your code. You hit CORS, and the fix is one line — once you know which line.\n\n## What you'll learn\n\nBy the end of this guide you'll be able to:\n\n- Explain, precisely, what the browser is and isn't protecting when it enforces CORS\n- Tell whether a given `fetch` will be sent as a \"simple\" request or trigger a preflight `OPTIONS` round trip, before you open dev tools\n- Read a CORS console error and map it to the exact response header that's missing or wrong\n- Configure a server (with or without a framework) to allow the right origins, methods, and credentials — and only those\n- Avoid the two mistakes that cause the most CORS incidents: an unscoped wildcard, and combining `*` with credentials\n\n**Who this is for:** you've written a `fetch` or `axios` call to an API on a different host and hit an error you fixed by copy-pasting a header without fully understanding why it worked.\n\n## Contents\n\n- [Why CORS exists](#why-cors-exists)\n- [The mental model](#the-mental-model-cors-is-the-browser-enforcing-a-promise-the-server-makes)\n- [Stage 1: what makes two URLs the same origin](#stage-1-what-makes-two-urls-the-same-origin)\n- [Stage 2: simple requests vs. preflighted requests](#stage-2-simple-requests-vs-preflighted-requests)\n- [Stage 3: reading and answering a preflight](#stage-3-reading-and-answering-a-preflight)\n- [Stage 4: credentials, cookies, and the wildcard trap](#stage-4-credentials-cookies-and-the-wildcard-trap)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\n## Why CORS exists\n\nHere's the naive expectation: if a server responds to a request, the calling JavaScript should be able to read the response. That's how requests to your own domain behave, so it's a reasonable thing to assume — until you point `fetch` at a different origin.\n\n```js\n\u002F\u002F running on https:\u002F\u002Fapp.example.com\nfetch(\"https:\u002F\u002Fapi.other-service.com\u002Fprofile\")\n  .then((res) => res.json())\n  .then((data) => console.log(data))\n  .catch((err) => console.error(err));\n\n\u002F\u002F Console:\n\u002F\u002F Access to fetch at 'https:\u002F\u002Fapi.other-service.com\u002Fprofile' from origin\n\u002F\u002F 'https:\u002F\u002Fapp.example.com' has been blocked by CORS policy: No\n\u002F\u002F 'Access-Control-Allow-Origin' header is present on the requested resource.\n```\n\nOpen the Network tab while this runs and the request often shows a `200` with a full JSON body sitting right there. The server did its job. The browser is the one refusing to hand that body to your script, and it does this on purpose: without this check, any site you happen to have open in a tab could quietly issue authenticated requests to your bank, your email provider, or your company's internal API — using cookies your browser is already sending for you — and read the responses. This is the **same-origin policy**, and it has protected the web since the mid-1990s. CORS (Cross-Origin Resource Sharing) is not a separate restriction bolted on top of it — it's the mechanism a server uses to selectively *relax* that policy for origins it trusts.\n\n## The mental model: CORS is the browser enforcing a promise the server makes\n\n**The mental model:** CORS does not stop your request from reaching the server. It stops your *JavaScript* from reading a cross-origin *response*, unless the server's response headers explicitly say that origin is allowed to see it. For a large class of requests, the request is sent, the server executes it, and only the read-back is blocked.\n\nThat last sentence is the part almost everyone gets backwards, and it has a real consequence: if your cross-origin request is a `POST` that writes to a database, and the response is missing the right CORS header, **the write still happened**. Your JavaScript just never finds out it succeeded. A \"CORS error\" in the console is not evidence that nothing happened server-side — it's evidence that the browser hid the outcome from you.\n\nPicture it as a two-party handshake with the browser standing in the middle:\n\n1. Your page, on origin A, asks the browser to fetch a resource on origin B.\n2. The browser sends the request (sometimes after first asking permission — more on that below).\n3. Origin B's response comes back with headers like `Access-Control-Allow-Origin: https:\u002F\u002Fapp.example.com`.\n4. The browser checks that header against the page's own origin. Match → your JavaScript gets the response. No match, or no header at all → the browser throws the response away and your `fetch` promise rejects.\n\nOrigin B never has to know your page exists in advance. It just has to say, in every response, which origins it's willing to let read that response. That's the entire protocol.\n\n## Stage 1: what makes two URLs the same origin\n\nAn **origin** is the triple of **scheme + host + port**. Change any one of the three and you have a different origin, even if the URLs look nearly identical:\n\n| URL | Same origin as `https:\u002F\u002Fapp.example.com:443`? | Why |\n| --- | --- | --- |\n| `https:\u002F\u002Fapp.example.com\u002Fsettings` | ✅ Yes | Different path, same scheme\u002Fhost\u002Fport |\n| `http:\u002F\u002Fapp.example.com` | ❌ No | Different scheme (`http` vs `https`) |\n| `https:\u002F\u002Fapi.example.com` | ❌ No | Different host (subdomain counts) |\n| `https:\u002F\u002Fapp.example.com:8443` | ❌ No | Different port |\n\nThis trips people up locally more than in production: `http:\u002F\u002Flocalhost:3000` (your frontend dev server) and `http:\u002F\u002Flocalhost:8080` (your backend) are **different origins**, because the port differs. That \"works on my machine, breaks in the console\" moment during local development is usually this, not a real production concern — though you still need the same CORS configuration in dev as you'll need once the frontend and API genuinely live on different hosts. If you parse or compare `origin` values yourself rather than trusting `req.headers.origin` blindly, [parsing URLs safely](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fthe-url-is-your-best-state-manager-5hl8) is worth getting right — a malformed or attacker-controlled origin string is not something you want to string-match casually.\n\n## Stage 2: simple requests vs. preflighted requests\n\nNot every cross-origin request behaves the same way. The [Fetch specification](https:\u002F\u002Ffetch.spec.whatwg.org\u002F#cors-safelisted-method) defines a **\"simple request\"**: one the browser sends directly, with no extra round trip, because it matches a narrow safelist:\n\n- Method is `GET`, `HEAD`, or `POST`.\n- The only headers you set by hand are ones on the CORS-safelisted list — `Accept`, `Accept-Language`, `Content-Language`, or `Content-Type` (see below).\n- If `Content-Type` is set, its value is one of exactly three: `text\u002Fplain`, `multipart\u002Fform-data`, or `application\u002Fx-www-form-urlencoded`.\n\nNotice what's missing from that list: `application\u002Fjson`. This is the single most common surprise in CORS debugging. A request that looks simple —\n\n```js\nfetch(\"https:\u002F\u002Fapi.example.com\u002Forders\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application\u002Fjson\" },\n  body: JSON.stringify({ item: \"widget\" }),\n});\n```\n\n— does **not** qualify, because `application\u002Fjson` isn't one of the three safelisted content types. The browser has to find out, *before* sending your `POST`, whether the server accepts it. So it sends a **preflight**: a separate `OPTIONS` request, automatically, that your code never sees or triggers directly.\n\n> **Key concept:** the preflight isn't extra ceremony the spec invented to slow you down — it exists because a `POST` with an arbitrary JSON body and custom headers is exactly the shape of request that could carry side effects (write a row, charge a card), so the browser confirms permission *before* letting that request out the door, rather than after the fact like it does for simple requests.\n\n## Stage 3: reading and answering a preflight\n\nOpen the Network tab on the JSON `POST` above and you'll see two requests, not one. First, the browser-generated preflight:\n\n```http\nOPTIONS \u002Forders HTTP\u002F1.1\nOrigin: https:\u002F\u002Fapp.example.com\nAccess-Control-Request-Method: POST\nAccess-Control-Request-Headers: content-type\n```\n\nThe server must answer with headers that explicitly cover what was asked:\n\n```http\nHTTP\u002F1.1 204 No Content\nAccess-Control-Allow-Origin: https:\u002F\u002Fapp.example.com\nAccess-Control-Allow-Methods: POST, GET, OPTIONS\nAccess-Control-Allow-Headers: Content-Type\nAccess-Control-Max-Age: 600\n```\n\nOnly if every requested method and header is covered does the browser send the real `POST`. Here's the same thing wired up with Express, which is the shape you'll write by hand before reaching for a library:\n\n```js\nimport express from \"express\";\nconst app = express();\n\napp.use((req, res, next) => {\n  const allowedOrigin = \"https:\u002F\u002Fapp.example.com\";\n  res.setHeader(\"Access-Control-Allow-Origin\", allowedOrigin);\n  res.setHeader(\"Vary\", \"Origin\"); \u002F\u002F see \"Edge cases\" — required when reflecting one origin\n  res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\");\n  res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type\");\n  res.setHeader(\"Access-Control-Max-Age\", \"600\"); \u002F\u002F cache the preflight result (seconds)\n\n  if (req.method === \"OPTIONS\") {\n    return res.sendStatus(204); \u002F\u002F preflight gets no body, just the headers above\n  }\n  next();\n});\n\napp.post(\"\u002Forders\", express.json(), (req, res) => {\n  res.json({ ok: true, item: req.body.item });\n});\n```\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fposts\u002F2026-08-03-weekly-cors-explained\u002Fplayground\u002F)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\nAlmost nobody hand-writes this in production — the [`cors`](https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002Fcors) npm package does exactly this with a friendlier config surface — but understanding this middleware means you can read what any library is doing under the hood, and debug it when it isn't doing what you expect.\n\n## Stage 4: credentials, cookies, and the wildcard trap\n\nBy default, `fetch` does not send cookies on a cross-origin request. If your API relies on a session cookie, you opt in explicitly:\n\n```js\nfetch(\"https:\u002F\u002Fapi.example.com\u002Fme\", { credentials: \"include\" });\n```\n\nThe moment you do this, two things become mandatory on the server side, and getting either wrong produces a CORS error that looks identical to the plain missing-header case:\n\n```js\nres.setHeader(\"Access-Control-Allow-Origin\", \"https:\u002F\u002Fapp.example.com\"); \u002F\u002F NOT \"*\"\nres.setHeader(\"Access-Control-Allow-Credentials\", \"true\");\n```\n\n`Access-Control-Allow-Origin: *` and credentials are mutually exclusive by design in the CORS spec — a wildcard means \"any origin may read this,\" and combined with cookies that would mean any site on the internet could read a logged-in user's data through your API. Browsers refuse to honor `*` alongside `Access-Control-Allow-Credentials: true`; you must name the exact origin. This is the second most common CORS incident after the `Content-Type` preflight surprise: someone sets `*` to \"just make CORS work,\" it does — until credentials get added later, and it silently stops working with no code change on the client at all.\n\n## Edge cases and gotchas\n\n**CORS is enforced by the browser, not the server.** A request from `curl`, Postman, a mobile app, or another backend service ignores CORS headers entirely — there's no JavaScript execution context for the browser to protect. \"It works in Postman but not in my web app\" is not a contradiction; it's the expected outcome, because Postman was never subject to this check in the first place.\n\n**Reflecting an origin requires `Vary: Origin`.** If your server supports multiple allowed origins by checking the incoming `Origin` header against an allowlist and echoing it back, you must also send `Vary: Origin`. Without it, a shared cache (a CDN, or even the browser's own HTTP cache) can serve a response with origin A's `Access-Control-Allow-Origin` value to a request from origin B, silently breaking CORS for one origin while it looks fine for the other.\n\n**`Access-Control-Max-Age` has a browser-enforced ceiling.** You can ask the browser to cache a preflight result for a long time, but browsers cap how long they'll actually honor it regardless of the value you send (Chromium currently caps it well under 24 hours). Don't assume a large number eliminates preflight traffic entirely — measure it.\n\n**Redirects are re-checked.** If a cross-origin response is a redirect to another cross-origin URL, the CORS check applies again at the final destination. A misconfigured redirect target produces a CORS failure that looks like it's coming from the URL you called, when the actual problem is the URL you were redirected to.\n\n**`mode: \"no-cors\"` doesn't bypass CORS — it neuters the response.** Setting `fetch(url, { mode: \"no-cors\" })` lets the request go out without a CORS check, but the response comes back \"opaque\": your code can't read its status, headers, or body. It's useful for fire-and-forget beacons, not for anything you need data back from.\n\n**Same-origin is not the same as same-site.** `app.example.com` and `api.example.com` are different *origins* (different host) but the same *site* (same registrable domain). This distinction matters for cookie `SameSite` behavior, which is a related but separate mechanism from CORS — don't conflate the two when a cookie isn't showing up where you expect it.\n\n## Best practices\n\n**Reach for a real allowlist in production.** Keep an array or `Set` of exact allowed origins, check the incoming `Origin` header against it, and reflect only a match — never a bare `*` — once cookies, `Authorization` headers, or any sensitive data are involved.\n\n**Avoid it when** you're building a public, read-only, unauthenticated API. If there are no credentials and no sensitive data, `Access-Control-Allow-Origin: *` is legitimate and simpler than maintaining an allowlist — that's exactly what public CDNs and open data APIs use.\n\n**Prefer a bearer token over a cookie for cross-origin auth when you can.** An `Authorization: Bearer \u003Ctoken>` header sidesteps the credentials-and-wildcard interaction entirely, because tokens aren't sent automatically the way cookies are — you attach them explicitly per request, which also sidesteps CSRF concerns that cookie-based auth invites.\n\n**Set `Access-Control-Max-Age` deliberately on hot endpoints.** A chatty preflight on every request to a high-traffic endpoint is a real, measurable cost; caching it for even a few minutes removes a full round trip from most calls.\n\n**Don't try to catch a CORS failure and inspect it in JavaScript.** By design, the browser gives your `catch` block almost no detail — a generic `TypeError: Failed to fetch`, not \"missing Access-Control-Allow-Origin.\" The Network tab and console are where the real diagnosis happens, not `error.message`. This is also why pairing a cross-origin call with an [AbortController](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fyour-fetch-is-still-running-after-the-user-left-cmf) is worth doing regardless of CORS — a request that's blocked from being *read* by the browser can still be left running server-side until something cancels it.\n\n**Firing several cross-origin requests at once has the same CORS rules per-request.** Each origin gets its own preflight and its own header check — there's no batching. If you're using `Promise.allSettled` or `Promise.any` to [run several requests concurrently](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fyou-reach-for-promiseall-for-every-concurrent-request-heres-when-to-use-the-other-three-31d6), expect one bad CORS config on a single endpoint to fail only that request, not the whole batch.\n\n## FAQ\n\n### Why does my request work in Postman but fail in the browser?\n\nBecause CORS is a browser-only protection. Postman, `curl`, and server-to-server calls have no same-origin policy to enforce, so they never send a preflight and never check response headers. Only requests initiated by JavaScript running in a browser page are subject to CORS.\n\n### Can I just set `Access-Control-Allow-Origin: *` to fix a CORS error?\n\nIt fixes the error, but only use it when the endpoint returns no sensitive, credential-gated data. `*` cannot be combined with `Access-Control-Allow-Credentials: true` — browsers reject that combination — so it isn't an option at all once cookies or auth headers are involved.\n\n### Does CORS protect my server from unauthorized access?\n\nNo. CORS is enforced entirely by the requesting browser; a non-browser client can ignore it completely and hit your API directly. It protects *browser users* from malicious pages reading data on their behalf — it is not authentication, and it is not a substitute for checking credentials server-side.\n\n### Why did adding a `Content-Type: application\u002Fjson` header suddenly trigger a preflight?\n\nBecause `application\u002Fjson` isn't one of the three CORS-safelisted content-type values (`text\u002Fplain`, `multipart\u002Fform-data`, `application\u002Fx-www-form-urlencoded`). Any other `Content-Type`, or any custom header at all, moves the request out of \"simple\" and into \"preflighted.\"\n\n### Can I use CORS with cookies?\n\nYes: send the request with `credentials: \"include\"`, and have the server respond with `Access-Control-Allow-Credentials: true` and an exact (non-wildcard) `Access-Control-Allow-Origin`. Both sides must opt in explicitly.\n\n### Is CORS the same thing as CSRF protection?\n\nNo, and this is a common source of false security confidence. CORS controls whether *JavaScript can read a cross-origin response*; it does nothing to stop a cross-origin *form submission* or an `\u003Cimg>`-style GET, which are subject to a different set of rules. CSRF defenses (tokens, `SameSite` cookies) are a separate mechanism you still need.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fquiz\u002Ftake.html?post=2026-08-03-weekly-cors-explained)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n## Cheat sheet\n\n| Task | Header \u002F Code | Notes |\n| --- | --- | --- |\n| Allow one specific origin | `Access-Control-Allow-Origin: https:\u002F\u002Fapp.example.com` | Required exact match when credentials are used |\n| Allow any origin (no credentials) | `Access-Control-Allow-Origin: *` | Never combine with `Allow-Credentials` |\n| Support multiple origins | Reflect the request's `Origin` if it's in your allowlist | Must also send `Vary: Origin` |\n| Allow cookies cross-origin | `Access-Control-Allow-Credentials: true` + `credentials: \"include\"` on the client | No wildcard allowed |\n| Declare allowed methods | `Access-Control-Allow-Methods: GET, POST, OPTIONS` | Checked against the preflight's requested method |\n| Declare allowed headers | `Access-Control-Allow-Headers: Content-Type, Authorization` | Must list every custom header the client sends |\n| Cache the preflight | `Access-Control-Max-Age: 600` | Seconds; browsers cap the effective maximum |\n\n```js\n\u002F\u002F The whole pattern, copy-paste ready (Express, hand-rolled — swap for the\n\u002F\u002F `cors` package once you understand what it's automating for you).\nimport express from \"express\";\nconst app = express();\n\nconst ALLOWED_ORIGINS = new Set([\n  \"https:\u002F\u002Fapp.example.com\",\n  \"https:\u002F\u002Fstaging.example.com\",\n]);\n\napp.use((req, res, next) => {\n  const origin = req.headers.origin;\n  if (origin && ALLOWED_ORIGINS.has(origin)) {\n    res.setHeader(\"Access-Control-Allow-Origin\", origin);\n    res.setHeader(\"Vary\", \"Origin\");\n    res.setHeader(\"Access-Control-Allow-Credentials\", \"true\");\n  }\n  res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, PATCH, DELETE, OPTIONS\");\n  res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization\");\n  res.setHeader(\"Access-Control-Max-Age\", \"600\");\n\n  if (req.method === \"OPTIONS\") return res.sendStatus(204);\n  next();\n});\n```\n\n## Key takeaways\n\n- CORS is enforced by the **browser**, checking response headers against the calling page's origin — it does not stop the request from reaching (or executing on) the server.\n- A request is either **simple** (sent directly) or **preflighted** (an `OPTIONS` round trip first); a custom header or a JSON `Content-Type` is the most common trigger for the latter.\n- `Access-Control-Allow-Origin: *` and credentials (`Access-Control-Allow-Credentials: true`) can never be combined — name the exact origin once cookies or tokens are involved.\n- When reflecting one of several allowed origins, always send `Vary: Origin`, or a shared cache will hand your CORS headers to the wrong caller.\n- CORS is not authentication and not CSRF protection — it's a read-access rule for browser JavaScript, layered on top of, not instead of, your server's own auth checks.\n\nThat `TypeError: Failed to fetch` from the top of this article is now legible: check the Network tab, find the missing or mismatched header, and you'll know within a minute which of the four fixes above it needs. What's the CORS error that took you the longest to actually understand — not just patch with a wildcard? Tell me in the comments.\n\n---\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)\n- 💼 **LinkedIn** — [linkedin.com\u002Fin\u002Fparsa-jiravand](https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fparsa-jiravand\u002F)\n- ✉️ **Email** (work & contract inquiries): [bestpractice2026@gmail.com](mailto:bestpractice2026@gmail.com)",{"title":50,"canonical":451,"description":51},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fweekly-cors-explained","019fe660-cf2a-71df-b517-92ac7fe564f3",{"id":454,"locked":18},"019fe660-d64c-7288-a544-9803b4ced6f1",[456],{"id":33,"slug":34,"title":36,"_count":457},{"questions":39},[459],{"locale":13,"slug":34},{"id":33,"slug":34,"title":36,"_count":461,"questionCount":39},{"questions":39}]