Debugging CORS Errors That Only Appear in Production Deployments
You tested your API calls thoroughly in development. Everything worked. Then you deployed, and now every request from the frontend returns a CORS error. The frustrating part is that the code didn't change β the environment did.
CORS errors that only appear in production almost always come from infrastructure, not your application code. Knowing where to look cuts the debugging time from hours to minutes.
What You'll Learn
- How CORS actually works at the HTTP layer, including preflight requests
- Why your dev environment silently bypasses CORS checks
- The most common production infrastructure sources of CORS failures
- How to diagnose the exact problem using browser DevTools and
curl - How to fix CORS at the correct layer without introducing security holes
Prerequisites
You should be comfortable reading HTTP request/response headers and have access to your production server config (Nginx, Apache, or a cloud load balancer). Basic familiarity with browser DevTools is assumed. The examples use Node.js/Express and Nginx, but the concepts apply to any stack.
How CORS Actually Works (The Part Most Tutorials Skip)
CORS β Cross-Origin Resource Sharing β is enforced entirely by the browser. The server doesn't block the request; it either includes the right headers in its response or it doesn't. The browser then decides whether to hand the response to your JavaScript.
An origin is the combination of protocol, hostname, and port. https://app.example.com and https://api.example.com are different origins. So are http://localhost:3000 and http://localhost:4000.
For simple requests (GET, POST with certain content types), the browser sends the request and checks the response. For anything more complex β DELETE, PUT, requests with custom headers, or requests with Content-Type: application/json β the browser first sends a preflight OPTIONS request to ask permission. If that preflight fails, the actual request never goes out.
The key response headers you need to understand:
Access-Control-Allow-Origin: which origins are allowed (a specific origin or*)Access-Control-Allow-Methods: which HTTP methods are allowedAccess-Control-Allow-Headers: which request headers are allowedAccess-Control-Allow-Credentials: whether cookies and auth headers are allowedAccess-Control-Max-Age: how long the browser should cache the preflight result
Why Development Hides CORS Problems
In local development, your frontend and backend often run on the same host (localhost), sometimes even on the same port if you're using a bundler proxy. Vite, webpack-dev-server, and Create React App all have proxy settings that forward API requests from the dev server, making the browser think the requests are same-origin.
This proxy lives only in the dev toolchain. When you deploy, the frontend is served from one origin and the API from another β and there's no proxy to paper over the difference. The CORS checks that were silently bypassed in development now fail loudly in production.
Another common factor: browser extensions like CORS Unblock or Allow CORS are sometimes installed by developers and forgotten. They suppress CORS errors in the browser, making you think your config is fine when it isn't.
Common Production Sources of CORS Failures
Nginx and Reverse Proxy Misconfiguration
Nginx sitting in front of your application is one of the most frequent culprits. A common mistake is adding CORS headers in the Nginx config but forgetting to handle the OPTIONS preflight separately. Nginx will forward the OPTIONS request to your application, but if your application doesn't handle it, it returns a 404 or 405 β without any CORS headers β and the browser stops there.
Another trap: adding headers in an if block inside Nginx. Nginx's if is notoriously unpredictable and often strips or duplicates headers. Always handle CORS in location blocks, not conditionals.
A working Nginx CORS block for an API looks like this:
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain';
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
proxy_pass http://backend;
}
Note that the OPTIONS block returns 204 immediately without proxying to the backend. This is intentional β the backend never needs to see preflight requests when Nginx handles CORS.
CDN and Edge Caching Stripping Headers
CDNs like Cloudflare, AWS CloudFront, and Fastly can strip or override response headers unless you explicitly configure them not to. The default caching behavior on many CDNs does not forward Access-Control-Allow-Origin to the client β especially if the CDN cached a response that was fetched without a Origin header.
The result is that the first user to hit a URL gets the correct CORS headers. The CDN caches the response. The next user, from a different origin, gets the cached response β which is missing the Access-Control-Allow-Origin header for their origin.
The fix is to add Vary: Origin to your API responses. This tells CDNs to cache separate versions of the response per origin. You should also check your CDN's header forwarding rules and ensure CORS-related headers are in the allow-list for caching.
// Express: add Vary header alongside CORS headers
app.use((req, res, next) => {
res.setHeader('Vary', 'Origin');
next();
});
Environment-Specific Origin Whitelists
Many applications build an allowed-origins list from environment variables. This works fine until someone forgets to set the variable in the production environment, or sets it with a trailing slash (https://app.example.com/ instead of https://app.example.com), or uses the wrong subdomain.
Origins must match exactly. The browser sends the Origin header as https://app.example.com (no trailing slash). If your whitelist has https://app.example.com/, the string comparison fails and no header is returned.
// Express with cors package β safe whitelist approach
const cors = require('cors');
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(o => o.trim().replace(/\/+$/, '')); // strip trailing slashes
app.use(cors({
origin: (origin, callback) => {
// allow requests with no origin (curl, Postman, server-to-server)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS blocked for origin: ${origin}`));
}
},
credentials: true,
}));
Log the rejected origin in the error callback during your initial production debugging session. Seeing the exact string the browser is sending removes all guesswork.
Diagnosing the Exact Problem
Reading the Browser Network Tab Correctly
Open DevTools, go to the Network tab, and filter by the failing request. Look for two things: whether there's a preflight OPTIONS request before the actual request, and what headers the server actually returned.
Click the OPTIONS request (if present). Check the Response Headers panel. If Access-Control-Allow-Origin is missing entirely, the server isn't sending CORS headers at all β this is an application or proxy config issue. If the header is present but has the wrong value, you have a whitelist mismatch. If the header is present and correct but the actual (non-preflight) request still fails, the non-preflight response is missing the header β this is a classic CDN caching problem.
Just as CSS specificity bugs that only appear in production require you to inspect what the browser actually received rather than what you think you shipped, CORS debugging is about reading the real HTTP exchange, not your source code.
Using curl to Isolate the Issue
curl lets you send a manually crafted preflight request from your terminal, bypassing the browser entirely. This tells you exactly what the server returns.
# Simulate a CORS preflight from curl
curl -v -X OPTIONS https://api.example.com/endpoint \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
Look at the response headers in the output (lines starting with <). If Access-Control-Allow-Origin is in the response, your server is configured correctly and the problem is elsewhere β likely a CDN or proxy between the server and the browser. If it's missing, the server config is the problem.
Run the same command from inside your production server (using curl localhost/endpoint) to test the application without any reverse proxy or CDN in the path. If it works from inside but not from outside, the proxy layer is stripping or blocking the headers.
Fixing CORS at the Right Layer
Setting Headers on Your Application Server
The cleanest approach for most applications is to handle CORS entirely in the application, not in Nginx or a CDN. Your application has access to the full request context, including the incoming Origin header, and can make dynamic decisions. Nginx config is static and harder to maintain across environments.
If you're using Django REST Framework, FastAPI, or Express, all of them have mature CORS middleware libraries that handle the edge cases for you. Use them rather than setting headers manually β they get preflight handling and Vary headers right by default.
Avoid setting Access-Control-Allow-Origin: * on any endpoint that accepts credentials (cookies, Authorization headers). Browsers will reject this combination outright. You must echo back the specific requesting origin when credentials: true is involved.
Handling Preflight Requests Properly
Preflight requests are OPTIONS requests, and they must return a 2xx status. Some application frameworks route OPTIONS through authentication middleware, which rejects the request with a 401 before it reaches your CORS logic. This is a very common gotcha.
Make sure your auth middleware explicitly skips OPTIONS requests:
// Express β skip auth for OPTIONS preflight
app.use((req, res, next) => {
if (req.method === 'OPTIONS') {
return next(); // bypass auth, let cors() handle it
}
return authMiddleware(req, res, next);
});
Also consider increasing Access-Control-Max-Age in production. The default is often very short. Setting it to 86400 (one day) dramatically reduces the number of preflight round-trips for users, which improves real-world performance.
If your application has silent error behaviors in other areas, it's worth reading about how JavaScript APIs can fail silently in unexpected ways β CORS-related fetch failures can sometimes surface in similarly confusing ways if you're not checking response.ok properly.
Common Pitfalls to Avoid
- Duplicate headers. If both Nginx and your application set
Access-Control-Allow-Origin, the browser may receive two values and reject both. Pick one layer and remove the header from the other. - Wildcard plus credentials.
Access-Control-Allow-Origin: *andAccess-Control-Allow-Credentials: truetogether are invalid. The browser will block the response. - HTTP vs HTTPS mismatch. If your production frontend is on HTTPS but calls an HTTP API, you hit a mixed-content block before CORS even enters the picture. Make sure both are on HTTPS.
- Trailing slashes in origins. As noted earlier, origin comparison is exact string matching. Strip trailing slashes from your whitelist values.
- Caching preflight responses with wrong headers. If a browser cached a preflight response with the wrong (or missing) CORS headers, it won't re-send the preflight until the cache expires. During debugging, disable caching in DevTools or clear the cache to force fresh requests.
- Port mismatches in staging. Your staging environment might run on a non-standard port.
https://staging.example.com:8443is a different origin fromhttps://staging.example.com. Ensure your whitelist includes the port if needed.
Wrapping Up
CORS errors in production almost always trace back to infrastructure differences, not code bugs. The debugging path is straightforward once you know what to check.
Here are the concrete next steps to take right now:
- Open DevTools Network tab, find the failing
OPTIONSpreflight request, and read the actual response headers the server returned. - Run a manual
curlpreflight against your production endpoint to determine whether the problem is in your app server or in a proxy/CDN layer. - Check your Nginx (or equivalent) config for duplicate
add_headerdirectives and ensureOPTIONSrequests return204without being proxied to the backend. - Add
Vary: Originto your API responses and verify your CDN is configured to respect it. - Audit your environment variables for trailing slashes or wrong subdomains in your allowed-origins list, and add origin logging to the rejection path so future mismatches are instantly visible in your logs.
Frequently Asked Questions
Why does my CORS error only happen in production and not in development?
In development, bundler tools like Vite and webpack-dev-server use a proxy that makes API requests appear same-origin to the browser, bypassing CORS checks entirely. In production that proxy doesn't exist, so the browser enforces CORS between your frontend and API origins for real.
How do I fix a CORS error when my CDN is involved?
Add a 'Vary: Origin' header to your API responses so the CDN caches separate versions per origin. Also check your CDN's header forwarding rules to ensure Access-Control-Allow-Origin is not being stripped or overridden at the edge.
Can I use Access-Control-Allow-Origin with a wildcard and still send cookies?
No. Browsers reject the combination of 'Access-Control-Allow-Origin: *' and 'Access-Control-Allow-Credentials: true'. When credentials are involved you must echo back the specific requesting origin instead of using a wildcard.
Why is my CORS preflight request returning a 401 in production?
Authentication middleware is likely running before your CORS handler and rejecting the OPTIONS preflight request before it reaches CORS logic. Configure your auth middleware to skip OPTIONS requests so the preflight can complete and return a 2xx response.
How do I check if Nginx is the cause of my CORS error?
Run a curl preflight request directly to your application server on localhost (bypassing Nginx) and compare the response headers to what you get through Nginx. If the headers are correct on localhost but missing through Nginx, the reverse proxy config is the problem.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!