Application Development

CORS Error Works Locally Fails in Production

J
James Eriksson
··12 min read

Your CORS error in production is almost never a missing header. It's an origin mismatch: your frontend and backend have different domains in production that were the same (or masked by a dev proxy) locally. The browser enforces the rule in production; the proxy hid it in dev. This guide walks you through diagnosis and fixes using a systematic 5-step workflow.

Why CORS Works Locally but Fails in Production

CORS configuration is origin-based. In development, your frontend and backend often live on the same origin: localhost. Your Vite dev server might proxy API calls through the same port, making them appear to come from one source. Or you run both on localhost with different ports and disable the browser's CORS checks during development. Production is different: your frontend and API are deployed to separate domains. These are distinct origins. The browser enforces the rule.

Developers get surprised because the same code works in both places. But the environment is not identical. Local development tools are loose. Production is strict. Your Node.js backend did not change between environments. The access control did.

This is not a bug. CORS exists to prevent malicious websites from making requests on your users' behalf. It's a browser-level security feature. It only applies in production because that's where untrusted origins can attack. Localhost is safe because it's your machine.

The fix is not to disable CORS or add wildcard Allow-Origin headers. The fix is: make your backend aware of both origins (localhost for dev, your production domain for prod) and whitelist each one. Then the browser allows the request.

Is It Really CORS? Diagnosis Checklist

Before debugging, confirm the error is actually CORS. Sometimes a server crash looks like CORS to the browser.

Check these four things:

Browser console shows "blocked by CORS policy." This is the tell. Chrome and Firefox both report CORS failures with that exact phrase. If you see it, proceed. If you do not, you have a different problem: server error, network timeout, or auth failure.

The request works in Postman or curl. CORS is a browser rule. Postman, curl, and direct backend calls bypass it entirely. If your endpoint works from Postman but fails in the browser, CORS is the culprit. If Postman also fails, your backend is broken, not your CORS config.

The same code works locally. If it works on localhost but fails on your production domain, CORS is the likely cause. If it fails everywhere, it is not CORS.

The backend is actually responding. This is the false positive trap. If your backend crashes (missing environment variable, database connection failure), it returns a 500 or 502 error without CORS headers. The browser sees no Access-Control-Allow-Origin header and displays a CORS error message, even though the real problem is the backend crash. Check your server logs first. Look for 5xx errors. If your backend is returning a 5xx status, debug that first. Once the backend returns a 2xx or 4xx status, CORS becomes relevant.

Systematic Debug Workflow: 5-Step Checklist

Follow this sequence to isolate the problem:

Step 1: Inspect the OPTIONS request in the Network tab.

Open your browser's Network tab (F12 in Chrome or Firefox). Trigger the failing request. Look for an OPTIONS request to the same URL before your actual GET or POST. This is the CORS preflight. Click it.

The OPTIONS request means your browser is asking permission before sending the real request. If you see a 404 or 500 on the OPTIONS response, your backend does not handle preflight requests. This is common on reverse proxies that do not forward OPTIONS to the app.

If you see no OPTIONS request, your request is simple (GET with no custom headers) or it is already cached. Proceed to Step 2.

Step 2: Check the response headers.

Click the failing request (not the OPTIONS preflight, the actual GET or POST). Go to the Response Headers tab. Look for:

  • Access-Control-Allow-Origin: should be your frontend URL
  • Access-Control-Allow-Methods: should include GET, POST, PUT, or whatever you are using
  • Access-Control-Allow-Headers: should include Authorization if you are sending auth tokens

If any of these are missing, your backend is not sending CORS headers. If all are present, go to Step 3.

Step 3: Validate the origin value.

Check the value of Access-Control-Allow-Origin. Is it exactly your frontend domain? If it is a wildcard (*), that only works for requests without credentials (auth tokens, cookies). If it is localhost or a different domain, CORS will fail.

Go back to your browser's Application tab (not Response Headers). Under Cookies or Storage, note your frontend URL. It should match the Access-Control-Allow-Origin header exactly, including protocol (https not http) and port (if present).

Step 4: Check for middleware or proxy stripping headers.

If Step 2 showed the headers were present, but the browser still fails, a reverse proxy or API gateway between your browser and backend might be removing them. This is common with Nginx, AWS API Gateway, or CDNs.

Test directly: From a terminal, run a curl command with the Origin header. Replace the domain placeholder with your actual domain:

curl -H "Origin: https://your-domain.com" -H "Access-Control-Request-Method: GET" -X OPTIONS https://api.your-domain.com/endpoint

If the response includes the Access-Control-Allow-Origin header, the header made it through the proxy. If it does not, the proxy is stripping it.

If a proxy is the culprit, you need to configure the proxy to forward CORS headers, not just forward the request. For Nginx, add CORS directives to the upstream block.

Step 5: Review environment variables.

Check your backend's environment configuration. CORS origins are often loaded from environment variables, not hardcoded.

Run a simple check: log the origin value your backend is checking. In Node.js/Express:

app.use((req, res, next) => {
  console.log('CORS checking origin:', req.headers.origin);
  next();
});

Restart the server and check logs when the request fails. Is the origin being read correctly? Is it hardcoded to localhost?

If the backend is reading localhost as the allowed origin (because the code has a fallback default), that is your problem.

Common Causes and Fixes by Scenario

Hardcoded localhost in code.

Your backend has a hardcoded list of allowed origins. It looks like:

const allowed = ['http://localhost:3000'];

In production, your frontend is on a different domain. The request fails because localhost is not in the list.

Fix: Load origins from an environment variable. Set the CORS_ORIGIN variable in your environment and use it in development. Update the code:

const allowed = (process.env.CORS_ORIGIN || 'http://localhost:3000').split(',');

Missing production origin in backend config.

The backend has a list of allowed origins for production, but your production domain is not in it. Maybe only an old domain is listed.

Fix: Add your production domain to the allowlist before deploying. If you deployed first and the domain was missing, add it and redeploy.

Preflight OPTIONS request fails on reverse proxy.

Your backend handles CORS correctly. But it is behind an Nginx reverse proxy, and Nginx returns a 404 on OPTIONS requests because it does not forward them to the app.

The browser tries to send OPTIONS, gets 404, and stops.

Fix: Configure Nginx to forward OPTIONS requests to the backend:

location /api/ {
    proxy_pass http://backend:3000;
}

Nginx will forward OPTIONS by default. But if you have a strict location block that only allows GET and POST, add OPTIONS:

limit_except GET POST OPTIONS {
    deny all;
}

HTTP/HTTPS mismatch.

Your frontend is served over HTTPS. Your backend API is served over HTTP (no HTTPS). The browser blocks this by default, even before CORS checks. Some browsers will report it as a CORS failure.

Fix: Use HTTPS for both. Or for development only, use HTTP for both.

Credentials plus wildcard origin.

Your frontend sends authentication cookies or an Authorization header. Your backend responds with Access-Control-Allow-Origin: *. The browser rejects this combination.

You cannot use wildcard (*) with credentials. The browser requires an exact origin match.

Fix: Specify the exact origin:

res.header('Access-Control-Allow-Origin', req.headers.origin);

Or whitelist multiple origins:

const allowed = ['https://yourdomain.com', 'https://admin.yourdomain.com'];
if (allowed.includes(req.headers.origin)) {
  res.header('Access-Control-Allow-Origin', req.headers.origin);
}

Framework-Specific Fixes

Express.js

Use the cors middleware:

const cors = require('cors');
const allowed = (process.env.CORS_ORIGIN || 'http://localhost:3000').split(',');

app.use(cors({
  origin: function(origin, callback) {
    if (allowed.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
}));

In production, set the environment variable to your domain.

Django with django-cors-headers

Install: pip install django-cors-headers.

In settings.py:

INSTALLED_APPS = [
    'corsheaders',
...
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',
...
]

CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000').split(',')

Set the environment variable in production to your domain.

Next.js API Routes

Next.js API routes run on the same origin as your frontend by default, so same-origin requests do not need CORS. But if your frontend fetches from an external API, handle CORS in your API route middleware:

export default function handler(req, res) {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  if (req.method === 'OPTIONS') {
    return res.status(200).end();
  }
}

Nginx reverse proxy

If your backend is behind Nginx, add CORS headers in the upstream block:

server {
    listen 80;
    server_name api.yourdomain.com;

    location / {
        proxy_pass http://backend:3000;
        
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;

        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }
}

This approach centralizes CORS at the proxy layer instead of in your app code. It is useful if your backend does not expose CORS configuration.

Avoid the False Positive: Backend Health Check First

Before diving into CORS debugging, confirm your backend is actually running and responding.

Check the HTTP status code. In the Network tab, look at the failing request. Is it a 200, 401, 403, 404, 500, or 502? CORS errors typically show with a 2xx or 4xx status (200 OK, 401 Unauthorized, 404 Not Found). If it is a 500 or 502, your backend crashed. Debug that first.

Check server logs. SSH into your production server or check your hosting provider's log viewer. Look for error messages in the last minute. Are there database connection errors? Missing environment variables? Uncaught exceptions? Fix those before touching CORS.

Test the endpoint directly. From your production server (or a terminal with curl), call the endpoint. Does it respond? If it returns a 5xx error or times out, your backend is down.

Distinguish between scenarios: If the backend is up but the browser shows CORS error, CORS is the problem. If the backend is down (500/502 error in server logs), fix the backend first. The CORS error is a symptom, not the root cause.

Production Readiness with Ship

Deploying to a managed platform like Ship reduces CORS misconfiguration risk. Ship handles the reverse proxy layer for you. You do not manually configure Nginx CORS rules. You do not guess about header forwarding.

Ship environment-based configuration lets you set CORS_ORIGIN per deployment: dev gets localhost:3000, staging gets your staging domain, production gets your production domain. Ship injects these values automatically. No manual domain entry in production.

When you deploy your app on Ship, you can use environment variables with confidence. Set your backend to read CORS origins from process.env.CORS_ORIGIN, and Ship ensures the value is correct for each environment.

This does not eliminate CORS misconfigurations, but it eliminates the human errors: hardcoding domains, forgetting to add production domains, mismatching HTTP and HTTPS. For teams focused on shipping features, not wrestling with proxy layers, Ship handles the infrastructure piece.

Start with Ship's deploy from GitHub feature to set up automated deployments. As you scale, use Ship's AI deployment option to simplify setup. And before your first paying customer, work through the production readiness checklist to catch environment misconfigurations early.

Frequently Asked Questions

Can I use Access-Control-Allow-Origin: * in production?

Yes, but only if your frontend does not send credentials (authentication cookies or Authorization headers). Wildcard origins work for unauthenticated requests. If you need to send auth, specify the exact origin instead.

Why does it work with curl but not the browser?

CORS is a browser-enforced rule. curl and Postman are not browsers, so they do not enforce it. If it works in curl but fails in the browser, CORS is the issue.

Should I handle CORS in my app code or in Nginx?

Either works. Handling it in your app (Express middleware, Django settings) is simpler for small teams. Handling it in Nginx is more scalable for teams with many backend services. Pick one and stick with it; doing both can cause header conflicts.

Can CORS errors expose sensitive data?

No. CORS errors block the response from reaching the browser. Sensitive data inside the response is not leaked. But misconfigured CORS (e.g., allow-all wildcard with credentials) can expose data by allowing unauthorized origins to read responses.

What does 'preflight' mean?

A preflight request is an OPTIONS request the browser sends before the actual request to check if CORS allows it. If preflight passes, the browser sends the real request. If preflight fails (no CORS headers), the browser stops and never sends the real request.

How do I test CORS locally without changing my code?

Use a dev proxy. Vite, Create React App, and Next.js dev servers can proxy API calls to your backend under the same origin, bypassing CORS checks. Or use a browser extension that disables CORS (for development only).

Is CORS slowing down my requests?

Slightly. Preflight requests add a round trip for non-simple requests. But CORS itself (header checking) is negligible. If you are seeing slow requests, the issue is the extra HTTP round trip, not CORS processing.

The Bottom Line

CORS errors in production are almost always an environment mismatch: your backend is checking for localhost, but your frontend is on a production domain. Diagnose with a systematic workflow: check the Network tab for preflight requests, verify response headers, validate the origin value, and test with curl. Most fixes are one-line environment variable changes.

Do not disable CORS. Do not use wildcard origins with credentials. Instead, whitelist your exact origins: localhost for dev, your production domain for production. Load these values from environment variables, not hardcoded strings. If you are configuring a reverse proxy or load balancer, ensure it forwards CORS headers and handles OPTIONS requests.

Deploy on Ship and use environment-based configuration to avoid these mistakes in the first place.

Deploy with environment-based CORS setup
Ship simplifies production deployment by handling reverse proxy configuration and injecting environment variables for your app.
Get Started Free

Ready to self-host your own apps?

One server. Multiple apps. No per-app fees.

Get started →