Common Mistakes and How to Fix Them
I've been fixing CORS issues — my own and other people's — for the better part of a decade. The same mistakes come up over and over. Not because developers are bad at their jobs, but because CORS is a system with a lot of interacting parts and the feedback you get when something is wrong (a cryptic browser console error) rarely points directly at the root cause.
This chapter is a catalog. Find your symptom, read the cause, apply the fix. If you want to understand why CORS works this way, the earlier chapters have the theory. This chapter is for when it's 4 PM on a Friday and production is broken.
Mistake #1: Missing Access-Control-Allow-Origin Header Entirely
The symptom:
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.
The cause: Your server simply isn't sending the Access-Control-Allow-Origin
header. This is the most basic CORS issue — you haven't configured CORS at all, or
your CORS middleware isn't running for this particular route.
The fix: Add the header to your server's responses.
Access-Control-Allow-Origin: https://myapp.com
Or, if this is a public API:
Access-Control-Allow-Origin: *
Why it happens: New APIs often don't think about CORS until a frontend tries to call them from the browser. You tested with curl or Postman, everything worked, and then the browser said no. CORS headers are opt-in. If you don't add them, cross-origin requests are blocked by default. That's the entire point of the Same-Origin Policy.
Mistake #2: Using * with Credentials
The symptom:
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'.
The cause: Your frontend is sending credentials: 'include' (or
withCredentials = true for XHR), and your server is responding with
Access-Control-Allow-Origin: *. The spec forbids this combination.
The fix: On the server, instead of *, echo back the specific origin from the
request:
# Pseudocode — adapt to your framework
origin = request.headers.get('Origin')
if origin in allowed_origins:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Vary'] = 'Origin'
Why it happens: The wildcard * means "any origin can read this response." But
when credentials are involved, the browser needs the server to explicitly confirm that
this specific origin is allowed to make credentialed requests. A wildcard is too
broad — it would mean any site on the internet could make authenticated requests to
your API using your user's cookies. The spec authors decided that's a bad idea
(they were right) and prohibited it.
Note: you also can't use * for Access-Control-Allow-Headers or
Access-Control-Allow-Methods when credentials are included. The same rule applies
to all three.
Mistake #3: Not Handling OPTIONS/Preflight Requests
The symptom:
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: It does not have HTTP ok status.
In the Network tab, you'll see an OPTIONS request with a 404 or 405 status.
The cause: The browser sends an OPTIONS request before certain cross-origin requests (those with custom headers, non-simple methods, or non-simple content types). Your server doesn't have a handler for OPTIONS and returns an error.
The fix: Handle OPTIONS requests for your CORS-enabled routes. Most CORS middleware does this for you, but if you're configuring it manually:
# What the browser sends:
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
# What your server should respond:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Why it happens: Most routing frameworks only create handlers for the methods you
explicitly register (GET, POST, PUT, etc.). Nobody writes app.options('/api/data', ...) because it's not part of your API's business logic. CORS middleware adds these
handlers automatically, but if the middleware isn't installed, or isn't applied to
this route, OPTIONS falls through to a 404 or the framework's default 405 response.
Mistake #4: Adding CORS Headers Only to Success Responses
The symptom: CORS works fine when the API returns 200, but when the API returns 400, 401, 403, or 500, you get a CORS error in the browser instead of the actual error.
The cause: Your CORS middleware or configuration only runs on successful responses.
When the server returns an error, it bypasses the CORS middleware, and the response
goes out without Access-Control-Allow-Origin.
The fix: Ensure your CORS headers are added to every response, regardless of status code. In most frameworks, this means making sure CORS middleware runs before your route handlers and wraps the entire response pipeline.
// Express — WRONG: CORS middleware after auth
app.use(authMiddleware); // might return 401 before CORS runs
app.use(cors());
// Express — RIGHT: CORS middleware before everything
app.use(cors());
app.use(authMiddleware);
# Nginx — add headers in the server/location block, not just for 200
add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
# ^^^^^^
# The 'always' parameter makes Nginx add the header even on error responses
Why it happens: This is the sneakiest CORS issue because it's intermittent. Your API works perfectly in development (where most responses are 200) and then breaks in production the first time a user hits a validation error or their session expires. The error response is there, with a perfectly good JSON body explaining what went wrong, but the browser can't show it to your JavaScript because the CORS header is missing.
Your frontend error handler gets a CORS error instead of a 401, so it can't show "Please log in again." It shows a generic network error instead. Your users are confused. You are confused. Everyone is confused.
Mistake #5: Duplicate CORS Headers
The symptom:
The 'Access-Control-Allow-Origin' header contains multiple values
'https://myapp.com, https://myapp.com', but only one is allowed.
Or sometimes the response contains two different origins:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Origin: *
The cause: Two layers of your stack are both adding CORS headers. Common culprits:
- Nginx adds CORS headers and your application adds CORS headers
- An API gateway adds CORS headers and your backend adds CORS headers
- A CDN (CloudFront, Cloudflare) adds CORS headers and your origin server adds them
The fix: Pick one layer to handle CORS and remove it from the other. My recommendation: handle CORS at the application level, because it has the most context about which origins should be allowed and which endpoints need credentials. But if you're using a managed API gateway, it might make more sense to configure CORS there.
# Debug with curl to see both headers:
curl -v -H "Origin: https://myapp.com" https://api.example.com/data 2>&1 | grep -i "access-control-allow-origin"
# If you see the header twice, that's the problem:
# < access-control-allow-origin: https://myapp.com
# < access-control-allow-origin: https://myapp.com
Why it happens: Because CORS is often an afterthought bolted on after deployment, multiple teams might add it at their layer without knowing someone else already did. DevOps adds it to Nginx. The backend team adds it to Express. Both are doing the right thing individually. Together, they're producing an invalid response.
Mistake #6: Not Including Vary: Origin
The symptom: CORS works from https://app1.example.com but breaks from
https://app2.example.com, or vice versa, seemingly at random. Clear the cache and
it works again, then breaks for the other origin.
The cause: Your server echoes back the request's Origin header as the value of
Access-Control-Allow-Origin, but doesn't include Vary: Origin in the response.
This means caches (CDNs, browser cache, proxy caches) might serve a response with
Access-Control-Allow-Origin: https://app1.example.com to a request from
https://app2.example.com.
The fix:
Access-Control-Allow-Origin: https://app1.example.com
Vary: Origin
Always. Every time. If you dynamically set Access-Control-Allow-Origin based on the
request's Origin header, you must include Vary: Origin.
Why it happens: HTTP caching is based on the URL by default. A cache sees a
request for https://api.example.com/data and thinks "I have a cached response for
that!" But the cached response has Access-Control-Allow-Origin: https://app1.example.com,
and the new request is from app2. The Vary header tells caches "this response
varies depending on the Origin request header, so you need to store separate cached
copies for each origin."
You might be thinking "but I don't have a CDN." Your browser has a cache too. This can bite you locally.
Mistake #7: Typos in Allowed Origins
The symptom: CORS doesn't work. You check the server config. The origin is right there in the allowed list. But it's not working.
The cause: Subtle typos that are hard to spot:
# These are all WRONG:
https://myapp.com/ # trailing slash
http://myapp.com # wrong scheme (http vs https)
https://myapp.com:443 # explicit default port (origin doesn't include it)
https://www.myapp.com # www subdomain
https://myApp.com # wrong case (origins are case-sensitive in the scheme
# and host, though browsers lowercase the host)
The fix: An origin is exactly: scheme://host[:port]. No path. No trailing
slash. No query string. The port is only included if it's non-default (not 80 for
http, not 443 for https).
# See exactly what origin the browser sends:
# In DevTools Network tab, click the request, look at Request Headers for "Origin"
# It will be something like: Origin: https://myapp.com
# Test your server with that exact value:
curl -v -H "Origin: https://myapp.com" https://api.example.com/data
Why it happens: Origins look like URLs but they're not. URLs have paths and query strings. Origins don't. It's easy to copy a URL from the address bar and paste it into your CORS config without trimming it down to just the origin portion.
Mistake #8: Allowing localhost in Production
The symptom: No error message per se, but a security review (or a penetration
tester) flags that your production API's CORS config includes http://localhost:3000.
The cause: During development, you added localhost to your allowed origins. Then
you deployed to production without removing it.
The fix: Use environment-specific CORS configuration:
const allowedOrigins = process.env.NODE_ENV === 'production'
? ['https://myapp.com', 'https://admin.myapp.com']
: ['http://localhost:3000', 'http://localhost:5173'];
Why it happens: Development. You needed it to work locally, you added it, you forgot to remove it. It doesn't cause any errors, so there's no signal that it's still there.
Why it matters: If your API uses cookies or other ambient credentials, allowing
http://localhost:3000 means that if an attacker can trick a user into running a
local server on port 3000 (or if the user is a developer who happens to have something
running there), that local page could make credentialed requests to your production
API. Is this a likely attack? Not very. Is it something a pen tester will flag? Every
single time.
Also note: it's http, not https. Localhost over plain HTTP is a different origin
than localhost over HTTPS. And neither belongs in a production CORS config.
Mistake #9: Not Exposing Custom Response Headers
The symptom: Your API returns custom response headers (like X-Request-ID,
X-RateLimit-Remaining, or Link), and they show up in DevTools, but
response.headers.get('X-Request-ID') returns null in your JavaScript.
The cause: By default, CORS only exposes these "simple" response headers to JavaScript:
Cache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
Everything else is hidden unless the server explicitly exposes it.
The fix:
Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining, Link
Why it happens: The CORS spec defaults to exposing only "safe" response headers. Custom headers might contain sensitive information that the server doesn't want arbitrary cross-origin pages to read. So the server has to opt in to exposing each one.
This is particularly maddening because you can see the headers in DevTools. They're
right there. But DevTools isn't subject to CORS — it's a browser developer tool, not
a web page. Your JavaScript code is subject to CORS, so it can't see them without
Access-Control-Expose-Headers.
Mistake #10: Confusing CORS with Content Security Policy
The symptom: You've added Access-Control-Allow-Origin and CORS is working, but
requests are still being blocked. Or you've added CSP headers and think that handles
CORS too.
The cause: CORS and CSP are two completely different mechanisms that happen to both involve HTTP headers and cross-origin resource loading.
| CORS | CSP | |
|---|---|---|
| Direction | Controls who can read your server's responses | Controls what your page can load |
| Who sets it | The server being accessed | The server that served the HTML page |
| Enforced by | Browser, on the response | Browser, on the page |
| Header | Access-Control-Allow-Origin | Content-Security-Policy |
| Protects | The server's data from unauthorized reading | The page's users from injected content |
The fix: Understand which one you need:
- If your frontend can't read a response from another origin → That's CORS. Fix it on the API server.
- If your frontend can't load a script, image, or connect to an API → That might
be CSP. Check the
Content-Security-Policyheader on your frontend's server.
# This is CSP (on your frontend's server):
Content-Security-Policy: connect-src 'self' https://api.example.com;
# This is CORS (on the API server):
Access-Control-Allow-Origin: https://myapp.com
Why it happens: Both are security mechanisms, both involve cross-origin resources, both produce console errors. It's natural to confuse them. But they solve different problems and are configured in different places.
Mistake #11: Using a CORS Proxy in Production
The symptom: Your development setup routes API requests through
https://cors-anywhere.herokuapp.com/ or a similar CORS proxy service, and you've
deployed this to production.
The cause: In development, you hit a CORS error. You searched Stack Overflow. Someone suggested a CORS proxy. It worked. You shipped it.
The fix: Remove the CORS proxy and configure CORS properly on the actual server. If you don't control the server, talk to whoever does. If it's a third-party API that doesn't support CORS, make the request from your backend server (server-to-server requests don't have CORS restrictions) and expose it through your own API.
// WRONG: proxying through a third-party service in production
fetch('https://cors-anywhere.herokuapp.com/https://api.example.com/data')
// RIGHT: call your own backend, which calls the third-party API server-side
fetch('https://myapp.com/api/proxy/data')
Why it happens: CORS proxies are a legitimate development tool. They work by receiving your request, making the same request to the actual server (server-to-server, no CORS), and forwarding the response back with permissive CORS headers added.
The problem with using them in production:
- Security: You're routing all your API traffic (including auth tokens, user data, everything) through a third party's server. They can read and log everything.
- Reliability: Free CORS proxy services have rate limits, downtime, and no SLA. cors-anywhere has been rate-limited for years.
- Performance: You're adding an extra network hop to every request.
- Professionalism: If someone inspects your network traffic and sees
cors-anywherein the URLs, they will draw conclusions.
Mistake #12: Setting CORS Headers in Frontend Code
The symptom: You've added headers to your fetch() call and CORS still doesn't
work:
// This does absolutely nothing useful for CORS
fetch('https://api.example.com/data', {
headers: {
'Access-Control-Allow-Origin': '*', // NO
'Access-Control-Allow-Methods': 'GET, POST', // NO
'Access-Control-Allow-Headers': 'Content-Type', // NO
}
});
The cause: A fundamental misunderstanding of which side of the HTTP exchange is responsible for CORS.
The fix: Remove all Access-Control-* headers from your frontend code. These
headers must be set by the server in its response. Your frontend sends a
request. The server sends a response with CORS headers. The browser reads those
headers and decides whether to allow your JavaScript to see the response.
Setting these headers in your request doesn't just do nothing — it actually makes
things worse. Access-Control-Allow-Origin is not a CORS-safelisted request header,
so adding it to your request will trigger a preflight request that wouldn't have
happened otherwise. You're creating a new CORS problem while trying to solve one that
doesn't exist on the frontend.
Why it happens: The header names are confusing. Access-Control-Allow-Origin
sounds like it should go on the request — "I'm allowing access from this origin."
But it's a response header — the server is saying "I allow this origin to read
my response." CORS is a server-side configuration that's enforced client-side. The
frontend's role is to make the request; the server's role is to say whether that
request is allowed.
Think of it like a bouncer at a club. You (the browser) ask "can this person (the frontend's JavaScript) come in?" The bouncer (the server) says yes or no. You don't get to write yourself a permission slip.
Quick Reference: Error → Mistake → Fix
| Error message (Chrome) | Likely mistake | Section |
|---|---|---|
| No 'Access-Control-Allow-Origin' header | #1, #4 | Missing header or error responses |
| Must not be the wildcard '*' when credentials... | #2 | Wildcard with credentials |
| Preflight request doesn't pass | #3 | OPTIONS not handled |
| Method X is not allowed | #3 | Method not in allow list |
| Request header field X is not allowed | #3, #9 | Header not in allow list |
| Multiple values but only one is allowed | #5 | Duplicate headers |
| Does not match | #7 | Origin typo |
| No error, but header value is null | #9 | Not exposed |
| Blocked by CSP | #10 | Wrong mechanism entirely |
| Works in Postman, not in browser | #1, #12 | Missing server config |
If your issue isn't in this chapter, go back to Chapter 19's decision tree. If it's still not clear, check whether you're actually dealing with a CORS issue at all — network errors, DNS failures, and blocked mixed content can all masquerade as CORS problems if you're not careful.