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.
📋 Table of Contents
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.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment