Web

CORS Errors: Why They Happen and How to Actually Fix Them

CORS is the browser security mechanism every web developer eventually fights. Learn the actual model (it's not what you think), the headers, the preflight dance, and the right way to configure it.

11 min read

Every web developer fights CORS at some point. The mistake people make is treating it as an obstacle to bypass, when really it's a security mechanism with specific rules. Once you understand the actual model — not the "just allow everything" folklore — CORS errors stop being mysterious.

The Same-Origin Policy

Browsers enforce the Same-Origin Policy: by default, JavaScript at https://app.example.comcannot read responses from https://api.example.com via fetch/XHR. The policy protects against malicious sites stealing data from sites you're logged in to.

"Origin" means the combination of scheme (http/https), host (app.example.com), and port (default or explicit). Any difference makes them different origins. https://example.com andhttps://www.example.com are different origins. http://localhost:3000 andhttp://localhost:3001 are different origins.

The misconception

The browser still sends the request — that's not what CORS prevents. The server sees and processes it. CORS prevents the browser's JavaScript from reading the response, unless the server has explicitly opted in. This matters for understanding why CORS errors happen on the client even when the server logs show the request succeeded.

How CORS opts in

For the browser to allow JavaScript to read a cross-origin response, the server must include specific headers in its response.

Simple requests

A "simple" request is GET, HEAD, or POST with a content type from a small whitelist (application/x-www-form-urlencoded, multipart/form-data, ortext/plain) and no custom headers.

The browser sends the request with an Origin header. The server response must include:

Access-Control-Allow-Origin: https://app.example.com

Or a wildcard:

Access-Control-Allow-Origin: *

Without one of these, the browser blocks the JavaScript from reading the response. The actual data was already sent and received — but the browser quarantines it.

Preflight (OPTIONS) requests

For non-simple requests (POST with JSON body, custom headers, methods like PUT/DELETE), the browser first sends an OPTIONS preflight to ask the server what's allowed:

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

The server must respond with the matching allow headers:

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

Only after a successful preflight does the browser send the actual request. This is why custom headers and JSON content types frequently trigger CORS errors that GET requests don't.

The error messages

"No 'Access-Control-Allow-Origin' header is present"

Server didn't set the header at all. Either CORS isn't configured server-side, or your request didn't reach the part of the server that adds it (e.g., 500 error before middleware ran).

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

You're sending cookies or auth headers (credentials: 'include' in fetch), but the server returned Allow-Origin: *. Cookies require the server to specify an explicit origin, plus Access-Control-Allow-Credentials: true.

"Method PUT is not allowed by Access-Control-Allow-Methods"

Preflight succeeded but said only certain methods are allowed. Server needs to add PUT to the allow list.

"Request header field X-Custom-Header is not allowed"

Same idea for headers. Server needs to add the header name to Access-Control-Allow-Headers.

Common configurations and pitfalls

1. Allow-Origin must match exactly

https://example.com and https://example.com/ are different. httpvs https are different. Match the Origin header exactly (or use a server-side whitelist that compares case-sensitively).

2. Wildcard doesn't work with credentials

If your API uses cookies for auth, you can't use Access-Control-Allow-Origin: *. You must echo back the specific origin from the request:

# Server-side pseudocode
allowed = ['https://app.example.com', 'https://app2.example.com']
if request.headers['Origin'] in allowed:
    response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    response.headers['Vary'] = 'Origin'

The Vary: Origin header is critical — without it, intermediate caches will serve the wrong response to other origins.

3. Preflight responses must succeed (2xx)

If your server returns 401 or 403 for OPTIONS without the CORS headers, the browser blocks the actual request. Configure auth middleware to skip OPTIONS or handle preflight before auth checks.

4. Set Access-Control-Max-Age

Without a max-age, browsers preflight every request. Set Access-Control-Max-Age: 86400(24 hours) to cache the preflight result, dramatically reducing OPTIONS traffic.

Don't blanket-allow everything

Access-Control-Allow-Origin: * with Allow-Headers: * andAllow-Methods: * bypasses CORS but exposes your API to every site on the internet. If your API has any auth-protected endpoints, this is a serious security regression. Whitelist origins explicitly.

What CORS doesn't protect

Common misconceptions:

  • It doesn't protect server-to-server requests. Curl, Python, Node.js, and other backend code ignore CORS entirely. CORS is a browser-only mechanism.
  • It doesn't prevent CSRF. CSRF involves forms and image tags, which aren't blocked by CORS. Use CSRF tokens or SameSite cookies.
  • It doesn't hide the response from the network. The data was sent. A network capture sees it. Browser quarantines only the JavaScript-readable response.
  • It doesn't protect img, script, or video elements. These can load cross-origin without CORS by default — though they have their own restrictions on script access.

The dev workarounds (and why most are bad)

Browser extensions that disable CORS

Disable CORS in your browser to make local development work. Fine for testing — but mask real production issues. Don't ship code that worked "only with extension on."

Setting up a development proxy

Webpack, Vite, Next.js, etc. can proxy API requests through the dev server, bypassing CORS in development. Most realistic and recommended:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
};

Disabling CORS server-side "temporarily"

You'll forget. It'll ship to prod. Don't do this — fix the actual configuration once and for all.

Production-ready CORS setup

For most applications:

  1. Maintain a server-side allowlist of trusted origins.
  2. Echo back the Origin header value if it's on the allowlist; otherwise reject.
  3. Set Access-Control-Allow-Credentials: true if using cookies.
  4. Set Vary: Origin to prevent cache poisoning.
  5. List only the methods and headers your API actually uses.
  6. Set Access-Control-Max-Age: 86400.
  7. Make OPTIONS responses fast (no auth, no DB lookups).

Key Takeaways

  • CORS is a browser security mechanism, not a server one. The request still reaches the server; CORS just prevents JavaScript from reading the response.
  • Allow-Origin must exactly match the Origin header (or be wildcard *). Wildcards don't work with credentials.
  • Custom headers and JSON POSTs trigger preflight (OPTIONS) requests. Preflight responses must include matching allow headers.
  • Set Vary: Origin when echoing back specific origins, or caches will serve wrong responses.
  • Production CORS = whitelist of origins, explicit method/header lists, Max-Age caching, OPTIONS fast path.