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.