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.
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:
- Status code: always
0 - Headers: empty
- Body: unreadable
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.
SameSite cookie alignment
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.
Related
- Core CORS Mechanics & Same-Origin Policy Fundamentals — parent reference: browser enforcement model, origin tuple, and the full CORS algorithm
- Simple vs Preflight Requests — which request properties trigger the OPTIONS handshake
- Origin Matching Rules & Validation — exact string matching logic and allowlist patterns
- Credential Sharing & Security Boundaries —
withCredentials, cookie scoping, and the wildcard prohibition - Dynamic Origin Validation Patterns — server-side allowlist implementation in Nginx and Express
- Debugging Missing Access-Control-Allow-Origin Header — step-by-step fix for the most common CORS failure