CORS Error Code Breakdown

Without a precise mapping between browser console output and the underlying HTTP exchange, CORS failures become a guessing game — you see a generic TypeError: Failed to fetch while the actual cause is hidden two layers deeper in a WAF rule or a misconfigured reverse proxy. This page maps every browser-reported error surface to its root cause and fix path.

This is a focused reference within Core CORS Mechanics & Same-Origin Policy Fundamentals. That parent covers the full browser enforcement model; this page goes deeper on the specific error signatures, their HTTP-level causes, and the diagnostic sequence to resolve each one.

Spec anchor: The WHATWG Fetch specification, §4.10 (CORS check algorithm), defines exactly when a browser blocks a cross-origin response. Every error listed below traces back to a branch in that algorithm — not to a browser quirk.


Error Surface Reference Table

The table below is the core diagnostic key. Match the console string and Network tab status to pinpoint the failure layer before touching any configuration.

Network Tab Status Console String Failure Layer Root Cause
0 / (failed) Access to fetch at '…' from origin '…' has been blocked by CORS policy Browser policy enforcement Response received, then blocked — CORS headers missing or mismatched
200 OK No 'Access-Control-Allow-Origin' header is present Missing response header Server responded successfully but emitted no CORS headers
200 OK The value of the 'Access-Control-Allow-Origin' header … does not match Header value mismatch Server returned a static or wrong origin value
403 Forbidden Response to preflight … has invalid HTTP status code 403 Server / WAF routing OPTIONS request rejected by firewall, auth middleware, or missing route
405 Method Not Allowed Response to preflight … has invalid HTTP status code 405 Framework routing Server has no handler for OPTIONS; framework default returned 405
400 Bad Request Response to preflight … has invalid HTTP status code 400 Header validation Preflight Access-Control-Request-Headers value rejected by the server
net::ERR_FAILED Network error TCP / TLS layer Handshake failure, DNS error, firewall drop — not a CORS issue
200 OK (XHR) The 'Access-Control-Allow-Origin' header contains multiple values Duplicate header injection Middleware and CDN/proxy both added the header; browser rejects the list

How Browsers Separate CORS Errors from HTTP Errors

This distinction is the most common source of confusion. A CORS block is not an HTTP status code — it is a client-side enforcement step that runs after the HTTP response is received.

Browser CORS enforcement sequence Timeline showing that the HTTP response is fully received before the browser runs the CORS check. A passing check exposes the response to JavaScript; a failing check produces a TypeError regardless of the HTTP status code. Browser Network Server fetch(url) HTTP request Process req HTTP 200 OK response CORS check (Fetch §4.10) ✓ JS receives response body TypeError: Failed to fetch (HTTP status hidden) check fails check passes

This matters for debugging: when you see TypeError: Failed to fetch, you cannot infer the HTTP status from the error itself. Open the Network tab, find the request, and read the response code directly.


Preflight 403 / 405 / 400 Failure Diagnostics

An OPTIONS preflight is issued automatically by the browser before any simple vs preflight request boundary is crossed: custom headers, non-simple methods (PUT, PATCH, DELETE), or Content-Type: application/json all trigger it. If the preflight fails, the actual request is never sent.

Spec rule

Per WHATWG Fetch §4.8.7, the preflight response must return HTTP status in the range 200–299. Any 4xx or 5xx aborts the CORS check immediately.

Why preflights fail

WAF / CDN blocking OPTIONS

Many web application firewalls block or drop OPTIONS requests by default. The browser receives a 403 from the WAF before the request reaches the origin server. The CORS middleware never runs.

# Test whether the WAF is the blocker — send OPTIONS directly with curl
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/resource

If you see < HTTP/2 403 and no Access-Control-Allow-* headers in the response, the WAF is intercepting before the application.

Framework routing returning 405

Express, Django, Rails, and most frameworks ignore OPTIONS by default unless a route is registered. Without an explicit handler, the router returns 405 Method Not Allowed.

// Express: register the OPTIONS handler BEFORE authentication middleware
const corsOrigins = ['https://app.example.com', 'https://admin.example.com'];

function isValidOrigin(origin) {
  return corsOrigins.includes(origin);
}

// This must appear before app.use(authMiddleware)
app.options('*', (req, res) => {
  const origin = req.headers.origin;
  if (!isValidOrigin(origin)) return res.sendStatus(403);
  res.set({
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Request-ID',
    'Access-Control-Max-Age': '600',
    'Vary': 'Origin'
  });
  res.sendStatus(204);
});

Header mismatch returning 400

When the Access-Control-Request-Headers value in the preflight contains a header name that the server does not explicitly list in Access-Control-Allow-Headers, some servers return 400 Bad Request. This is not a browser block — the server itself is rejecting the OPTIONS request.

Preflight response requirements

Header Required? Allowed Values Notes
Access-Control-Allow-Origin Yes Exact origin or * Must match Origin request header exactly
Access-Control-Allow-Methods Yes Comma-separated method list Must include the requested method
Access-Control-Allow-Headers Conditional Comma-separated header list Required if any non-simple headers are requested
Access-Control-Max-Age No Integer seconds Chrome cap: 7200 s; Firefox cap: 86400 s
Vary Yes Origin Must be present to prevent CDN cache poisoning

Origin Mismatch and Validation Failures

The Access-Control-Allow-Origin header must be an exact byte-for-byte match with the Origin header sent by the browser. Browsers perform this comparison per WHATWG Fetch §3.1 (origin serialization): scheme + host + port, case-sensitive, no trailing slash.

Common mismatch patterns

Sent Origin Returned Header Match? Reason
https://app.example.com https://app.example.com Yes Exact match
https://app.example.com https://app.example.com/ No Trailing slash — browsers strip it from Origin
https://app.example.com https://APP.EXAMPLE.COM No Host comparison is case-sensitive
https://app.example.com http://app.example.com No Scheme mismatch
https://app.example.com:443 https://app.example.com Depends Default port 443 is omitted in serialization; both forms are equivalent in practice, but reflect exactly what the browser sends

Dynamic origin reflection is the correct pattern for multi-origin APIs: validate the incoming Origin header against an explicit allowlist, then echo the validated origin verbatim. See dynamic origin validation patterns for allowlist implementation in Nginx and Express.

# Nginx: dynamic reflection with a map block
map $http_origin $cors_origin {
  default               "";
  "https://app.example.com"   $http_origin;
  "https://admin.example.com" $http_origin;
}

server {
  location /api/ {
    if ($cors_origin) {
      add_header Access-Control-Allow-Origin $cors_origin always;
      add_header Vary Origin always;
    }
    # ... proxy_pass
  }
}

Null-origin and sandboxed iframes

Browsers send Origin: null from sandboxed <iframe> elements (those without allow-same-origin), local file:// URLs, and some data-URI contexts. Servers that reflect the Origin header will reflect null, then return Access-Control-Allow-Origin: null. This works mechanically but is a security risk — any sandboxed document on any site can send Origin: null. Do not allowlist null unless access to the resource is genuinely public.


Credential and Security Boundary Violations

When client code sets credentials: 'include' (Fetch API) or withCredentials = true (XHR), the browser enforces two additional rules beyond basic origin matching.

Rule 1: Access-Control-Allow-Origin must contain the exact requesting origin — never *.

Rule 2: The server must return Access-Control-Allow-Credentials: true.

Both rules must be satisfied simultaneously. Violating either produces the console error:

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'.

For the full credential isolation specification, see credential sharing and security boundaries.

Opaque responses and no-cors mode

Setting mode: 'no-cors' on a fetch request prevents the CORS preflight and suppresses the CORS error — but the trade-off is severe: you receive an opaque response. An opaque response has:

Opaque responses are useful only when the resource is loaded for a side effect (e.g., preloading a font, pinging a URL) and the content never needs to be read. Using no-cors to silence a CORS error in an API call is not a fix — the response will be empty.

Access-Control-Expose-Headers and header visibility

By default, JavaScript can only read six “safe” response headers from a cross-origin response: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified. Any custom header (X-Request-ID, X-Rate-Limit-Remaining, etc.) is invisible unless the server explicitly lists it:

Access-Control-Expose-Headers: X-Request-ID, X-Rate-Limit-Remaining

Without this header, response.headers.get('X-Request-ID') returns null — silently, with no console error.

When cookies are involved in a credentialed cross-origin request, the cookie’s SameSite attribute must be None, and it must be marked Secure. A cookie set with SameSite=Lax or SameSite=Strict is not sent on cross-origin requests regardless of how CORS headers are configured.

SameSite Value Sent on Cross-Origin Request? Notes
None; Secure Yes Requires HTTPS
Lax No (for POST/non-navigational) Sent only on top-level GET navigations
Strict No Never sent cross-origin
(not set, legacy) Browser-dependent Chrome 80+ defaults to Lax

Edge Cases and Infrastructure Interactions

Duplicate Access-Control-Allow-Origin headers

A common production failure: the application server emits Access-Control-Allow-Origin: https://app.example.com, and a CDN or reverse proxy also adds the header. The response arrives with two Access-Control-Allow-Origin values. Browsers reject this:

The 'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one is allowed.

Fix: configure the CDN to pass the header from the origin rather than add its own, or remove the CORS header from one layer. Set Vary: Origin to prevent the CDN from serving the wrong cached origin to different clients.

CDN cache poisoning without Vary: Origin

Omitting Vary: Origin from CORS responses allows CDNs to cache a response with one origin’s headers and serve it to a different origin’s request. The second client receives the wrong Access-Control-Allow-Origin value and sees a CORS error — intermittently, and only from cache. This is difficult to reproduce locally and is one of the most common production-only CORS failures.

Reverse proxy header stripping

Some reverse proxy configurations (Nginx, HAProxy, AWS ALB) strip certain headers. If CORS headers are added by the application server but stripped before the response reaches the client, the browser sees a response with no CORS headers.

Test with curl directly to the origin server (bypassing the proxy) and again through the proxy to identify the stripping layer.


Systematic Debugging Workflow

DevTools and curl verification checklist

curl -v -X OPTIONS \
  -H "Origin: https://your-client.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  https://your-api.example.com/endpoint

Client-side error isolation wrapper

async function debugCorsRequest(url, options = {}) {
  try {
    const response = await fetch(url, { ...options, mode: 'cors' });
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    return response;
  } catch (err) {
    if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
      // The browser blocked the response due to CORS policy.
      // The actual HTTP status is only visible in DevTools — it cannot be read from JS.
      console.error('[CORS] Request blocked by browser policy. Check DevTools Network tab for the HTTP status and response headers.');
      console.error('[CORS] Run: curl -v -X OPTIONS -H "Origin: <your-origin>" ' + url);
    }
    throw err;
  }
}

For a targeted walkthrough of debugging a missing Access-Control-Allow-Origin header, including step-by-step resolution for the most common server stacks, see the dedicated guide.


Common Mistakes

Issue Technical Impact Mitigation
Using Access-Control-Allow-Origin: * with credentials: 'include' Browser rejects immediately; TypeError: Failed to fetch on every credentialed request Reflect the validated origin dynamically; never use * with credentials
Assuming a 4xx/5xx response means a CORS error Leads to chasing CORS config when the real issue is authentication or server logic Check the Network tab status code first; only investigate CORS if headers are absent
Omitting Access-Control-Allow-Headers from preflight responses Browser aborts the actual request with a network error when any custom header is present List every non-simple header the client sends; automate from the Access-Control-Request-Headers value
Not registering an OPTIONS route before auth middleware Preflight gets a 401 or 403 from auth before reaching CORS logic Register app.options('*', ...) as the first route, before any middleware that enforces authentication
Missing Vary: Origin on cached CORS responses CDN serves one client’s origin header to a different origin, causing intermittent CORS failures Always add Vary: Origin to any response that includes Access-Control-Allow-Origin
Serving CORS headers only on application routes, not on error pages A 404 or 500 response without CORS headers causes a secondary CORS failure, hiding the original error Configure the CORS middleware to apply to all responses, including error handlers

FAQ

Why does the browser show a CORS error when the server returns a 200 OK?

The browser received the response but blocked it from JavaScript due to missing or mismatched CORS headers. The browser enforces same-origin policy at the client level, per the WHATWG Fetch spec CORS check algorithm. Even a successful HTTP 200 is hidden from your code if Access-Control-Allow-Origin is absent or does not match.

How do I distinguish a true CORS error from a network timeout?

Check the Network tab: CORS errors show a completed request with a real HTTP status code alongside a specific console warning referencing “CORS policy”. Network timeouts display net::ERR_CONNECTION_TIMED_OUT or net::ERR_NAME_NOT_RESOLVED with no response headers at all and typically no status code.

Can I bypass CORS errors in production by modifying client code?

No. CORS is a browser security enforcement mechanism. Client-side workarounds like mode: 'no-cors' only yield opaque responses with no readable body, headers, or status code. Server-side header configuration is the only valid fix.

Why does a preflight request return 403 Forbidden?

The server or an intermediary (WAF, CDN, or load balancer) rejected the OPTIONS method, failed to validate the requested headers, or denied the origin in its routing or firewall logic. Confirm that your framework routes OPTIONS requests to the CORS handler before authentication middleware, and that WAF rules permit the OPTIONS method.

Why does CORS still fail when Access-Control-Allow-Origin is present?

Common causes: the header value is * while credentials are included (the browser rejects this combination), the reflected origin has a trailing slash or mismatched scheme, Access-Control-Allow-Headers does not list all custom headers the request sends, or a CDN cached a response without Vary: Origin and is serving the stale version to a different origin.