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.
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
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.comOr 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, AuthorizationThe 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: 86400Only 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, orvideoelements. 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:
- Maintain a server-side allowlist of trusted origins.
- Echo back the
Originheader value if it's on the allowlist; otherwise reject. - Set
Access-Control-Allow-Credentials: trueif using cookies. - Set
Vary: Originto prevent cache poisoning. - List only the methods and headers your API actually uses.
- Set
Access-Control-Max-Age: 86400. - 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.