🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

How to Fix CORS Preflight OPTIONS Request Failing in Production

⏱️4 min read  ·  824 words

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.

✍️ Leave a Comment

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

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা