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-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

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.

CORSCSP
DirectionControls who can read your server's responsesControls what your page can load
Who sets itThe server being accessedThe server that served the HTML page
Enforced byBrowser, on the responseBrowser, on the page
HeaderAccess-Control-Allow-OriginContent-Security-Policy
ProtectsThe server's data from unauthorized readingThe 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-Policy header 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:

  1. 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.
  2. Reliability: Free CORS proxy services have rate limits, downtime, and no SLA. cors-anywhere has been rate-limited for years.
  3. Performance: You're adding an extra network hop to every request.
  4. Professionalism: If someone inspects your network traffic and sees cors-anywhere in 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 mistakeSection
No 'Access-Control-Allow-Origin' header#1, #4Missing header or error responses
Must not be the wildcard '*' when credentials...#2Wildcard with credentials
Preflight request doesn't pass#3OPTIONS not handled
Method X is not allowed#3Method not in allow list
Request header field X is not allowed#3, #9Header not in allow list
Multiple values but only one is allowed#5Duplicate headers
Does not match#7Origin typo
No error, but header value is null#9Not exposed
Blocked by CSP#10Wrong mechanism entirely
Works in Postman, not in browser#1, #12Missing 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.