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: 403Unlike 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, orPOST - The
Content-Typeheader is something other thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain - The request includes custom headers like
Authorization,X-Custom-Header,X-Requested-With, etc. - The request uses
ReadableStreamin 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, AuthorizationThe server must respond with:
- A 2xx status code (typically
200or204) - 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 responseDjango (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 responseThe 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, POSTThe 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, OPTIONSIn 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-WithNote 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 corsconst 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
alwaysparameter onadd_headerfor actual requests so the headers are included even on4xxand5xxerror responses. Withoutalways, nginx only adds headers to successful responses, and your frontend sees a CORS error instead of the real server error. - Return
204forOPTIONS— not a redirect, not a200with a body. - Do not add CORS headers in both nginx and the backend. Duplicate headers (two
Access-Control-Allow-Originvalues) 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):
- In the API Gateway console, select your resource (e.g.,
/data) - Click Actions > Enable CORS
- Set the allowed origin, methods, and headers
- Click Enable CORS and replace existing CORS headers
- 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, orPOST - The only custom headers are
Accept,Accept-Language,Content-Language, orContent-Type Content-Typeis one ofapplication/x-www-form-urlencoded,multipart/form-data, ortext/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:
- The server must include
Access-Control-Allow-Credentials: truein the preflight response Access-Control-Allow-Originmust be an exact origin — the wildcard*is not allowedAccess-Control-Allow-Headerscannot be*— each header must be listed explicitlyAccess-Control-Allow-Methodscannot 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: 86400This 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:
| Browser | Maximum |
|---|---|
| Chrome | 7200 (2 hours) |
| Firefox | 86400 (24 hours) |
| Safari | 604800 (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:
Authentication middleware intercepts the preflight. This is the most common cause after the basics are covered. The
OPTIONSrequest does not carry yourAuthorizationheader or session cookie. If your auth middleware rejects unauthenticated requests, it returns401or403before the CORS middleware runs. Move your CORS middleware to run before authentication, or explicitly excludeOPTIONSfrom auth checks.The server is not reachable at all. If the backend is down, the
OPTIONSrequest 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.A redirect on the OPTIONS request. If the
OPTIONSrequest gets a301or302redirect (e.g., fromhttp://tohttps://, or from/api/datato/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.The server returns 200 with an HTML error page. Some application servers return
200 OKwith an HTML error page body when an unhandled route is hit. The status is200but the CORS headers are missing. The browser sees a preflight failure. Check the actual response body of theOPTIONSrequest in DevTools’ Network tab.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.A WAF or firewall is blocking OPTIONS requests. Web Application Firewalls (AWS WAF, Cloudflare, corporate firewalls) sometimes block
OPTIONSrequests or strip CORS headers. Check your WAF rules and ensureOPTIONSis an allowed method.The preflight response has no
Content-LengthorContent-Type. Some strict proxies or HTTP/2 implementations require aContent-Length: 0header on the204response. If your preflight response hangs or times out, try addingContent-Length: 0explicitly.CORS configuration is cached at the CDN level. If you use CloudFront, Cloudflare, or another CDN, the CDN may be caching a previous
OPTIONSresponse that lacked the correct headers. Purge the CDN cache after changing your CORS configuration and addVary: Originto 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
Fix: Access to fetch has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header
How to fix 'Access to fetch at ... from origin ... has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource' in JavaScript. Covers Express, Django, Flask, Spring Boot, ASP.NET, nginx, Apache, dev proxies, preflight requests, credentials, and edge cases.
Fix: CORS Error with Credentials – Access-Control-Allow-Credentials and Wildcard Origin
How to fix CORS errors when using cookies or Authorization headers, including 'Access-Control-Allow-Credentials' and wildcard origin conflicts.
Fix: SyntaxError: Unexpected token < in JSON at position 0
How to fix 'Unexpected token in JSON at position 0', 'JSON.parse: unexpected character', and 'Unexpected end of JSON input' in JavaScript and TypeScript. Covers API returning HTML instead of JSON, Content-Type mismatches, fetch URL typos, invalid JSON syntax, BOM characters, CORS proxies, and debugging with response.text().
Fix: Yarn Integrity Check Failed – Expected and Got Different Results
How to fix the Yarn error 'integrity check failed' or 'Lockfile does not satisfy expected package' caused by corrupted cache, outdated lockfile, or version mismatches.