Fix: CORS preflight request blocked — Response to preflight does not have HTTP ok status

The Error

You make a cross-origin request from your frontend, and the browser blocks it before the actual request is even sent:

Chrome / Edge:

Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: It does not have HTTP ok status.

Chrome (XMLHttpRequest):

Access to XMLHttpRequest at 'https://api.example.com/data' from origin
'http://localhost:3000' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: It does not have HTTP ok status.

Firefox:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote
resource at https://api.example.com/data. (Reason: CORS preflight channel did
not succeed). Status code: 405.

Safari:

Preflight response is not successful. Status code: 403

Unlike the basic Access-Control-Allow-Origin missing error, this one is specifically about the preflight request failing. The browser never sends your actual request because the preliminary OPTIONS check did not return a successful HTTP status code.

Why This Happens

When your JavaScript makes a cross-origin request that is not a “simple request,” the browser sends an automatic preflight request before the real one. This preflight is an HTTP OPTIONS request that asks the server: “Will you accept the actual request I’m about to send?”

A request triggers a preflight when any of the following are true:

  • The HTTP method is anything other than GET, HEAD, or POST
  • The Content-Type header is something other than application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • The request includes custom headers like Authorization, X-Custom-Header, X-Requested-With, etc.
  • The request uses ReadableStream in the body

The preflight OPTIONS request includes these headers to describe the actual request:

OPTIONS /data HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server must respond with:

  1. A 2xx status code (typically 200 or 204)
  2. The appropriate Access-Control-Allow-* headers

If the server returns 404, 405 Method Not Allowed, 403 Forbidden, 500, or any non-2xx status, the browser treats the preflight as failed and blocks the actual request entirely. The same happens if the server simply does not respond at all — in that case you may see an ERR_CONNECTION_REFUSED error instead.

Fix 1: Handle the OPTIONS Method on Your Server

The most common cause is that the server has no handler for OPTIONS requests. Many web frameworks only set up handlers for GET, POST, PUT, and DELETE, so when the browser sends OPTIONS, the server returns 404 or 405.

You need to explicitly accept OPTIONS requests and return the CORS headers with a 2xx status.

Express / Node.js (manual):

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
});

Python / Flask:

@app.after_request
def add_cors_headers(response):
    response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000'
    response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
    response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
    if request.method == 'OPTIONS':
        response.status_code = 204
    return response

Django (without django-cors-headers):

class CorsMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.method == 'OPTIONS':
            response = HttpResponse(status=204)
        else:
            response = self.get_response(request)

        response['Access-Control-Allow-Origin'] = 'http://localhost:3000'
        response['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
        response['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
        return response

The key detail is returning a 204 No Content (or 200 OK) for OPTIONS requests. If your framework routes OPTIONS to a handler that requires a request body or authentication, the preflight will fail.

Fix 2: Set Access-Control-Allow-Methods

Even if the server responds to OPTIONS with a 200 status, the preflight still fails if the Access-Control-Allow-Methods header does not include the method the browser wants to use.

For example, if your frontend sends a PUT request but the server only responds with:

Access-Control-Allow-Methods: GET, POST

The browser will block the PUT because it is not in the allowed list. Make sure to include every HTTP method your API uses:

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS

In Express with the cors middleware:

const cors = require('cors');

app.use(cors({
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
}));

Fix 3: Set Access-Control-Allow-Headers (Content-Type, Authorization)

The preflight request includes an Access-Control-Request-Headers header that lists the custom headers the actual request will send. The server must echo all of those back in Access-Control-Allow-Headers. If even one is missing, the preflight fails.

The most common culprits are Content-Type (when set to application/json) and Authorization.

Chrome error for missing header:

Request header field Authorization is not allowed by Access-Control-Allow-Headers
in preflight response.

Firefox error:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote
resource. (Reason: header 'authorization' is not allowed according to header
'Access-Control-Allow-Headers' from CORS preflight response).

The fix is to include all headers your frontend sends:

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

Note that Content-Type is only considered a “simple” header when its value is application/x-www-form-urlencoded, multipart/form-data, or text/plain. If you send Content-Type: application/json, it triggers a preflight and must be explicitly allowed. This catches many developers off guard — simply sending JSON from fetch is enough to trigger the preflight mechanism.

Fix 4: Express / Node.js cors Middleware

The easiest way to handle all of the above in Express is the cors middleware. It handles OPTIONS requests, sets the correct headers, and returns 204 for preflights automatically.

npm install cors
const express = require('express');
const cors = require('cors');

const app = express();

app.use(cors({
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// Your routes here
app.post('/api/data', (req, res) => {
  res.json({ message: 'success' });
});

A common mistake is placing the cors() middleware after other middleware that short-circuits the request. Authentication middleware is the usual offender — if it runs before cors(), it rejects the OPTIONS request (which carries no auth token) before the CORS headers are set. Always place cors() first:

// Correct order
app.use(cors({ origin: 'http://localhost:3000' }));
app.use(authMiddleware);  // Auth runs after CORS

// Wrong order — preflights get blocked by auth
// app.use(authMiddleware);
// app.use(cors({ origin: 'http://localhost:3000' }));

If your API is behind a path prefix and you get TypeError: Cannot read properties of undefined errors in your CORS config, make sure the origin option is a string or array, not an undefined variable from a missing environment variable.

Fix 5: Nginx Proxy Configuration

When nginx sits in front of your backend, it can handle the preflight itself. This is useful when you proxy requests to an application server that does not handle CORS natively. If your backend goes down, you might also see an nginx 502 Bad Gateway error instead of a CORS error, which can be confusing to debug.

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

    location /api/ {
        # Handle preflight
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://myapp.com';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
            add_header 'Access-Control-Max-Age' 86400;
            add_header 'Content-Length' 0;
            return 204;
        }

        # Headers for actual requests
        add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;

        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Key points:

  • Use the always parameter on add_header for actual requests so the headers are included even on 4xx and 5xx error responses. Without always, nginx only adds headers to successful responses, and your frontend sees a CORS error instead of the real server error.
  • Return 204 for OPTIONS — not a redirect, not a 200 with a body.
  • Do not add CORS headers in both nginx and the backend. Duplicate headers (two Access-Control-Allow-Origin values) cause the browser to reject the response entirely.

Fix 6: Apache .htaccess

For Apache servers, enable mod_headers and mod_rewrite, then add the following to your .htaccess file or virtual host configuration:

<IfModule mod_headers.c>
    Header set Access-Control-Allow-Origin "https://myapp.com"
    Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS"
    Header set Access-Control-Allow-Headers "Content-Type, Authorization"
    Header set Access-Control-Max-Age "86400"
</IfModule>

# Handle preflight OPTIONS requests
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} ^OPTIONS$
    RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>

If your Apache server proxies to a backend application, make sure the backend is reachable. A connection failure at the proxy level can result in a 503 error on the OPTIONS request, which the browser reports as a CORS preflight failure. See Fix: curl failed to connect for troubleshooting backend connectivity.

If your backend already sets CORS headers, do not set them again in Apache. Duplicate Access-Control-Allow-Origin headers cause the browser to reject the response. Use one layer only.

Fix 7: AWS API Gateway CORS Setup

AWS API Gateway requires explicit CORS configuration because it does not handle OPTIONS requests by default.

REST API (API Gateway v1):

  1. In the API Gateway console, select your resource (e.g., /data)
  2. Click Actions > Enable CORS
  3. Set the allowed origin, methods, and headers
  4. Click Enable CORS and replace existing CORS headers
  5. Deploy the API — changes do not take effect until you redeploy

If you use a Lambda proxy integration, API Gateway passes everything to your Lambda function, including OPTIONS requests. Your Lambda must return the CORS headers itself:

export const handler = async (event) => {
  const headers = {
    'Access-Control-Allow-Origin': 'https://myapp.com',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  };

  if (event.httpMethod === 'OPTIONS') {
    return { statusCode: 204, headers, body: '' };
  }

  // Your actual logic
  return {
    statusCode: 200,
    headers,
    body: JSON.stringify({ message: 'success' }),
  };
};

HTTP API (API Gateway v2):

HTTP APIs have a built-in CORS configuration. In the console, go to CORS under your API settings and configure the allowed origins, methods, and headers. This handles preflight automatically without needing a Lambda handler for OPTIONS.

Common mistake: Forgetting to redeploy the API after enabling CORS. The configuration change is saved, but the live API still uses the previous deployment.

Fix 8: Avoid Preflight by Using Simple Requests

If you control both the frontend and backend, you can sometimes avoid preflights entirely by restructuring your requests to qualify as “simple requests.” A request is simple when it meets all of these conditions:

  • The method is GET, HEAD, or POST
  • The only custom headers are Accept, Accept-Language, Content-Language, or Content-Type
  • Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain

For example, instead of sending JSON:

// This triggers a preflight because of Content-Type: application/json
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'test' })
});

You could send form data:

// This does NOT trigger a preflight
const formData = new URLSearchParams();
formData.append('name', 'test');

fetch('https://api.example.com/data', {
  method: 'POST',
  body: formData  // Content-Type defaults to application/x-www-form-urlencoded
});

This approach has limitations. You lose the ability to send complex nested JSON structures easily, and you cannot use custom headers like Authorization. For APIs that require an Authorization header, there is no way to avoid the preflight — the server must handle it.

In practice, properly configuring the server to handle preflights is the correct solution. Avoid-preflight hacks make your code harder to maintain for minimal benefit.

Fix 9: Credentials and Access-Control-Allow-Credentials

When your request includes cookies or HTTP authentication (credentials: 'include' in fetch, or withCredentials: true in axios), the preflight has additional requirements:

  1. The server must include Access-Control-Allow-Credentials: true in the preflight response
  2. Access-Control-Allow-Origin must be an exact origin — the wildcard * is not allowed
  3. Access-Control-Allow-Headers cannot be * — each header must be listed explicitly
  4. Access-Control-Allow-Methods cannot be * — each method must be listed explicitly

Frontend:

fetch('https://api.example.com/data', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'test' })
});

Backend (Express):

app.use(cors({
  origin: 'https://myapp.com',       // Not '*'
  credentials: true,                  // Sends Access-Control-Allow-Credentials: true
  methods: ['GET', 'POST', 'PUT', 'DELETE'],  // Not '*'
  allowedHeaders: ['Content-Type', 'Authorization']  // Not '*'
}));

If you use * for the origin while credentials mode is include, Chrome shows:

The value of the 'Access-Control-Allow-Origin' header in the response must not be
the wildcard '*' when the request's credentials mode is 'include'.

To support multiple origins with credentials, dynamically reflect the request’s Origin header after validating it against an allowlist, as described in the CORS Access-Control-Allow-Origin guide.

Fix 10: Preflight Caching with Access-Control-Max-Age

By default, browsers send a preflight OPTIONS request before every cross-origin request that requires one. This doubles the number of HTTP requests, adding latency. The Access-Control-Max-Age header tells the browser how long (in seconds) to cache the preflight result.

Access-Control-Max-Age: 86400

This caches the preflight for 24 hours. During that time, the browser skips the OPTIONS request for the same URL, method, and headers combination.

Express:

app.use(cors({
  origin: 'https://myapp.com',
  maxAge: 86400
}));

Nginx:

add_header 'Access-Control-Max-Age' 86400;

Browser limits on max-age:

BrowserMaximum
Chrome7200 (2 hours)
Firefox86400 (24 hours)
Safari604800 (7 days)

Chrome caps max-age at 2 hours regardless of what the server sends. Setting it higher than 7200 has no additional effect in Chrome, but Firefox and Safari respect higher values.

During development, preflight caching can hide configuration changes. If you modify your CORS headers and the preflight still fails, clear the preflight cache by opening DevTools, going to the Network tab, and checking Disable cache.

Still Not Working?

If you have handled OPTIONS requests and set all the correct headers but the preflight still fails, check these edge cases:

  1. Authentication middleware intercepts the preflight. This is the most common cause after the basics are covered. The OPTIONS request does not carry your Authorization header or session cookie. If your auth middleware rejects unauthenticated requests, it returns 401 or 403 before the CORS middleware runs. Move your CORS middleware to run before authentication, or explicitly exclude OPTIONS from auth checks.

  2. The server is not reachable at all. If the backend is down, the OPTIONS request gets no response, and the browser reports it as a CORS preflight failure. Check that the server is running and accessible. If you are developing locally and see connection errors, see ERR_CONNECTION_REFUSED on localhost for troubleshooting steps.

  3. A redirect on the OPTIONS request. If the OPTIONS request gets a 301 or 302 redirect (e.g., from http:// to https://, or from /api/data to /api/data/), the preflight fails. Browsers do not follow redirects on preflight requests. Update the frontend URL to point to the final destination directly, avoiding the redirect.

  4. The server returns 200 with an HTML error page. Some application servers return 200 OK with an HTML error page body when an unhandled route is hit. The status is 200 but the CORS headers are missing. The browser sees a preflight failure. Check the actual response body of the OPTIONS request in DevTools’ Network tab.

  5. Duplicate CORS headers. If both your reverse proxy (nginx, Apache) and your application set Access-Control-Allow-Origin, the browser receives two values for the same header. This causes the browser to reject the response with: "The 'Access-Control-Allow-Origin' header contains multiple values, but only one is allowed." Set CORS headers in one place only.

  6. A WAF or firewall is blocking OPTIONS requests. Web Application Firewalls (AWS WAF, Cloudflare, corporate firewalls) sometimes block OPTIONS requests or strip CORS headers. Check your WAF rules and ensure OPTIONS is an allowed method.

  7. The preflight response has no Content-Length or Content-Type. Some strict proxies or HTTP/2 implementations require a Content-Length: 0 header on the 204 response. If your preflight response hangs or times out, try adding Content-Length: 0 explicitly.

  8. CORS configuration is cached at the CDN level. If you use CloudFront, Cloudflare, or another CDN, the CDN may be caching a previous OPTIONS response that lacked the correct headers. Purge the CDN cache after changing your CORS configuration and add Vary: Origin to your responses so the CDN caches different versions per origin.


Related: For the basic CORS error where the Access-Control-Allow-Origin header is missing entirely (not a preflight issue), see Fix: No Access-Control-Allow-Origin header.

Related Articles