Skip to content

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

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix 'Response to preflight request doesn't pass access control check' and 'preflight channel did not succeed' CORS errors by handling OPTIONS requests, setting correct headers, and configuring your server.

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.

Why this matters: The preflight is not optional — it is the browser’s way of protecting users. If the server cannot prove it expects cross-origin requests, the browser blocks the actual request entirely. This prevents malicious websites from silently calling APIs your user is logged into.

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 the origin option resolves to undefined because an environment variable is missing, the cors middleware silently allows every origin in development and rejects them in production. Always provide a fallback (process.env.CORS_ORIGIN ?? 'http://localhost:3000') and fail fast on missing config rather than letting it default.

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.

How other web frameworks handle CORS

The OPTIONS handling and header logic is identical across every web framework, but the middleware names, defaults, and ordering quirks differ. If you switch stacks, knowing the surprise default for each framework saves an afternoon.

Express cors. The de facto Node.js middleware. Out of the box app.use(cors()) reflects the request’s Origin header back as the response value and allows every method, which is great for prototypes but unsafe for production. Authentication middleware ordering is the most common trap (covered in Fix 4). The middleware short-circuits OPTIONS with a 204 by default — disable that with preflightContinue: true if you need the next handler to run.

Fastify @fastify/cors. Configured through fastify.register(cors, { origin, methods }). Fastify enforces a strict request lifecycle, so the plugin must be registered before any route that needs CORS, including route-level authentication hooks. The preflightContinue: false default behaves the same as Express. Fastify’s hide-options-route option suppresses the auto-generated OPTIONS handler if you want to keep manual control.

Hono cors. Hono is a lightweight router used on Cloudflare Workers, Bun, and Deno. app.use('*', cors({ origin: 'https://myapp.com' })) registers the middleware. Hono runs at the edge, so the surprise is that Cloudflare’s own CORS rules in the dashboard can run before Hono ever sees the request — check both layers.

Koa @koa/cors. Koa uses an await next() pattern. app.use(cors({ origin: 'https://myapp.com', credentials: true })) works identically to Express, but Koa middleware that throws an error before await next() resolves can skip the CORS step entirely, producing a preflight failure that looks like a missing handler.

Django django-cors-headers. Added via corsheaders.middleware.CorsMiddleware in MIDDLEWARE. The middleware must be placed above CommonMiddleware or Django will redirect (and lose CORS headers) before the middleware runs. Settings are CORS_ALLOWED_ORIGINS, CORS_ALLOW_CREDENTIALS, and CORS_ALLOW_METHODS. Older guides recommend CORS_ORIGIN_ALLOW_ALL = True, which is equivalent to * and incompatible with credentials.

ASP.NET Core UseCors. Configured in Program.cs with builder.Services.AddCors(...) and app.UseCors("PolicyName"). The order in the middleware pipeline matters: UseCors must come after UseRouting but before UseAuthorization. A common trap is forgetting .AllowCredentials() on the policy when sending cookies — the framework silently strips the Access-Control-Allow-Credentials header otherwise.

Spring Boot @CrossOrigin. Annotate controllers or methods, or register a CorsConfigurationSource bean. The surprise is Spring Security: by default, Spring Security responds to OPTIONS with 401 unless you call http.cors(Customizer.withDefaults()) in your SecurityFilterChain. Without that, every preflight fails despite a perfectly valid @CrossOrigin annotation.

Rails rack-cors. Configured in config/initializers/cors.rb using a DSL with origins, resource, methods. The middleware lives in the Rack stack, so it runs before Rails routing — convenient, but it means a typo in the resource path silently allows nothing. Test with curl -X OPTIONS -H "Origin: https://myapp.com" ... to confirm headers come back.

Flask flask-cors. CORS(app, resources={r"/api/*": {"origins": "https://myapp.com"}}). Flask-CORS handles OPTIONS through the same view function, which means a @login_required decorator on the view also rejects the preflight unless you exempt OPTIONS explicitly.

The common pattern: every framework either short-circuits OPTIONS with a 204 (most middleware does this) or forwards it to your handler (rare, but the default in some lightweight routers). When in doubt, run curl -v -X OPTIONS -H "Origin: https://example.com" -H "Access-Control-Request-Method: POST" https://your-api/endpoint and read the headers directly — the framework choice falls away once you see the raw response.

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.

  9. Private network access preflight (PNA). Chrome 104+ sends an additional Access-Control-Request-Private-Network: true header when a public site requests a private IP (192.168.x.x, 10.x.x.x, localhost). The server must echo Access-Control-Allow-Private-Network: true or the preflight fails even though the standard CORS headers are correct. This bites local dev tools that expose localhost APIs to a public site. Verify by checking the request headers for the PNA-specific entries.

  10. HTTP/2 or HTTP/3 lowercasing breaking a strict reverse proxy. HTTP/2 enforces lowercase header names. A reverse proxy or WAF that does exact-case matching on Access-Control-Allow-Origin may silently strip the response header on HTTP/2 connections while passing it through on HTTP/1.1. Test the same endpoint with curl --http1.1 and curl --http2 and compare. Switch the proxy rule to case-insensitive matching.

  11. Service worker is intercepting the preflight. A service worker registered for the page can intercept the OPTIONS request via fetch event and respond with a synthesized response that lacks CORS headers. Check chrome://serviceworker-internals/ or DevTools → Application → Service Workers. Unregister the worker (or update it to pass through OPTIONS) and retry.

  12. The browser sent Sec-Fetch-Mode: cors to a server that refuses CORS by policy. Modern browsers add fetch metadata headers describing the request mode. Some hardened APIs check Sec-Fetch-Mode and reject anything other than same-origin. Unlike a missing CORS header this is a deliberate server policy — the only fix is to host the API on the same origin (via a reverse proxy or a backend-for-frontend) or have the API owner change the policy.


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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles