How to Fix CORS Preflight OPTIONS Request Failing in Production

Your API works in development but fails in production with CORS preflight request doesn’t pass access control check or the OPTIONS request returns 404/405. Preflight failures are a common production-only CORS problem. Here’s how to diagnose and fix them.

What Is a Preflight Request?

For “non-simple” requests (PUT, DELETE, custom headers, or JSON content-type), the browser sends an OPTIONS request first — the “preflight” — to ask the server if the real request is allowed. If the preflight fails, the actual request never runs. Preflight failures often appear only in production due to different server configs.

# Browser automatically sends this before your POST:
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

# Server MUST respond with matching CORS headers and 2xx status

Cause 1: Server Doesn’t Handle OPTIONS

// 🐛 Express route only handles POST, not OPTIONS preflight
app.post('/api/users', createUser);
// The OPTIONS preflight hits no handler → 404/405 → preflight fails

// ✅ Use the cors middleware which handles OPTIONS automatically
const cors = require('cors');
app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}));
// cors() responds to preflight OPTIONS requests before your routes

Cause 2: Reverse Proxy Strips or Blocks OPTIONS

# nginx — explicitly handle OPTIONS preflight
location /api/ {
    # Handle preflight
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' 86400 always;
        add_header 'Content-Length' 0;
        return 204;   # respond to preflight with 204 No Content
    }

    proxy_pass http://localhost:3000;
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
}

Cause 3: Wildcard Origin with Credentials

// 🐛 Wildcard origin CANNOT be used with credentials
app.use(cors({
  origin: '*',              // ❌ fails when credentials: true
  credentials: true,
}));
// Browser error: "Access-Control-Allow-Origin cannot be '*' when
// credentials mode is 'include'"

// ✅ Specify the exact origin
app.use(cors({
  origin: 'https://app.example.com',   // exact origin, not *
  credentials: true,
}));

// ✅ For multiple origins, echo the matching one
const allowed = ['https://app.example.com', 'https://admin.example.com'];
app.use(cors({
  origin: (origin, cb) => {
    if (!origin || allowed.includes(origin)) cb(null, true);
    else cb(new Error('Not allowed by CORS'));
  },
  credentials: true,
}));

Cause 4: Missing Allowed Headers

// The preflight lists headers the real request will send.
// The server must allow ALL of them, or preflight fails.

// 🐛 Real request sends Authorization but server doesn't allow it
allowedHeaders: ['Content-Type'],   // missing Authorization

// ✅ Allow every custom header your client sends
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-API-Key'],

// Check the browser's preflight to see what's requested:
// Access-Control-Request-Headers: authorization, content-type
// The server's Access-Control-Allow-Headers must include all of these

Cause 5: Auth Middleware Runs Before CORS

// 🐛 Auth middleware rejects the OPTIONS preflight (no auth header on preflight!)
app.use(authenticate);   // blocks OPTIONS with 401
app.use(cors());

// ✅ CORS must come BEFORE auth so preflight passes
app.use(cors({ origin: 'https://app.example.com', credentials: true }));
app.use(authenticate);   // preflight already handled by cors()
// Preflight OPTIONS requests carry no credentials by design —
// they must not be blocked by auth

Debugging Preflight Failures

# Simulate the preflight with curl to see the server response
curl -X OPTIONS https://api.example.com/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -i

# Check the response has:
# HTTP/1.1 204 (or 200)  ← must be 2xx
# Access-Control-Allow-Origin: https://app.example.com  ← matches
# Access-Control-Allow-Methods: ...POST...  ← includes your method
# Access-Control-Allow-Headers: ...content-type, authorization...

Frequently Asked Questions

Q: Why does it work in dev but fail in production?
A: Development often uses a proxy (Vite/CRA) that avoids CORS entirely, or a permissive dev CORS config. Production has a real reverse proxy (nginx) and stricter config where preflight handling must be explicit.

Q: Why does my OPTIONS request return 401?
A: Your auth middleware is running before CORS and rejecting the preflight (which carries no credentials by design). Put CORS middleware before auth so preflight requests pass through.

Q: What status code should the preflight return?
A: 204 (No Content) or 200. Any 2xx works. A 404, 405, or 401 means the preflight failed and the browser blocks the real request.

Q: Do I set CORS headers in nginx or the app — not both?
A: Pick one place. Duplicate Access-Control-Allow-Origin headers (from both nginx and Express) cause the browser to reject the response. Use nginx OR the app, not both.

Q: How do I allow credentials (cookies) cross-origin?
A: Set Access-Control-Allow-Credentials: true AND specify an exact origin (not *) on the server, and use credentials: 'include' in the client fetch. All three are required together.

Conclusion

CORS preflight failures in production usually come from: the server not handling OPTIONS, a reverse proxy blocking it, wildcard origin with credentials, missing allowed headers, or auth middleware rejecting the preflight. The fixes: use the cors middleware (or explicit nginx OPTIONS handling), specify exact origins when using credentials, allow every header your client sends, and put CORS before auth middleware. Debug with a curl -X OPTIONS request to see exactly what the server returns — the preflight must respond 2xx with matching CORS headers.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and editor of TechPulse. He writes about developer tools, hardware, AI, and practical technology decisions. Some articles are based on cited documentation and analysis rather than hands-on testing; readers should check each article for sources and testing disclosures. Corrections are welcome at rony.yf25@gmail.com.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *