CORS Preflight Request Blocked: The Complete Fix for Every Web Server
Your frontend calls an API. The browser fires an OPTIONS request before the actual request. The server does not respond with the right headers. The browser kills the request and prints a red CORS error in the console. I have debugged this on Nginx, Apache, Express, API Gateway, and Cloudflare Workers. Here is how to fix it on every single one.
TL;DR
The browser sends an OPTIONS preflight before the actual request. If your server does not respond with the correct Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers on the OPTIONS response, the actual request is blocked. The fix: handle the OPTIONS method explicitly on your server and return those three headers with valid values. Every server and framework handles this differently, so scroll to your stack below.
What Is a Preflight Request?
CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When your JavaScript on app.example.com makes a request to api.example.com, the browser considers that a cross-origin request. Before sending certain types of requests, the browser sends a "preflight" request using the HTTP OPTIONS method to ask the server: "Will you accept this request from this origin, with these methods and headers?"
Not every cross-origin request triggers a preflight. The browser classifies requests into two categories.
Simple Requests (No Preflight)
A request is considered "simple" and skips the preflight if it meets all of these conditions:
- The method is
GET,HEAD, orPOST - The only headers set are Accept, Accept-Language, Content-Language, or Content-Type
- The Content-Type (if set) is
application/x-www-form-urlencoded,multipart/form-data, ortext/plain
Preflighted Requests (OPTIONS First)
Everything else triggers a preflight. In practice, this means nearly every modern API call triggers one because:
- You set
Content-Type: application/json(not a simple content type) - You send an
Authorizationheader (not in the simple header list) - You use
PUT,PATCH, orDELETEmethods - You send custom headers like
X-Request-IDorX-API-Key
The flow looks like this:
- Your JavaScript calls
fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token123' } }) - The browser intercepts this and sends an OPTIONS request to the same URL first
- The server responds to OPTIONS with CORS headers indicating what is allowed
- If the headers permit it, the browser sends the actual POST request
- If the headers are missing or wrong, the browser blocks the request and logs a CORS error
Step 1: Confirm It Is a CORS Issue
Before changing any server config, confirm the problem. Open your browser DevTools (F12), go to the Network tab, and reproduce the failing request. Look for these signs:
What to Look for in DevTools
- A failed OPTIONS request with a
(blocked:mixed-content)or(failed)net::ERR_FAILEDstatus - The actual request (POST, PUT, etc.) never appears in the network tab at all
- The Console tab shows one of these errors:
Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to XMLHttpRequest at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
Request header field authorization is not allowed by
Access-Control-Allow-Headers in preflight response.
If you see these messages, the problem is confirmed. Your server is not returning the correct CORS headers on the OPTIONS response. If the OPTIONS request returns a 405 Method Not Allowed, your server does not handle OPTIONS at all, which is the most common root cause.
Step 2: The Three Required Headers
Every preflight response must include these three headers. Without all three, the browser will block the actual request.
Access-Control-Allow-Origin
Tells the browser which origins are allowed to make requests. This must match the Origin header from the request exactly, or be the wildcard *.
# Allow a specific origin
Access-Control-Allow-Origin: https://app.example.com
# Allow any origin (not recommended for authenticated APIs)
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods
Lists the HTTP methods the server accepts for cross-origin requests. Include every method your API uses.
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers
Lists the request headers the server accepts. This must include every custom header your frontend sends. The most common mistake is forgetting to list Authorization or Content-Type here.
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
There are additional optional headers you may need. Access-Control-Max-Age tells the browser how long (in seconds) to cache the preflight response, reducing repeated OPTIONS requests. Access-Control-Allow-Credentials must be set to true if your frontend sends cookies or Authorization headers with credentials: 'include'. Access-Control-Expose-Headers lists which response headers the browser should expose to the frontend JavaScript.
Step 3: Fix for Nginx
Nginx is the most common server I see CORS issues on because the default configuration has no OPTIONS handling at all. Here is the complete fix.
server {
listen 443 ssl;
server_name api.example.com;
# ... SSL config ...
location /api/ {
# Handle preflight OPTIONS requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' '86400' always;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' '0';
return 204;
}
# CORS headers for actual requests
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length, X-Request-ID' always;
proxy_pass http://backend;
}
}
Key points for the Nginx config:
- The
return 204sends a No Content response to the OPTIONS request without hitting your backend - The
alwayskeyword ensures headers are sent even on error responses (4xx, 5xx) - Using
$http_originechoes back the requesting origin, which is required whenAccess-Control-Allow-Credentialsistrue - You must add CORS headers on both the OPTIONS block and the actual request block. They are separate responses.
Step 4: Fix for Apache
Apache requires mod_headers and mod_rewrite to be enabled. You can place the config in your VirtualHost or .htaccess.
VirtualHost Configuration
<VirtualHost *:443>
ServerName api.example.com
# Enable required modules
# a2enmod headers rewrite
# Handle OPTIONS preflight
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
# Set CORS headers on all responses
Header always set Access-Control-Allow-Origin "https://app.example.com"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
Header always set Access-Control-Allow-Credentials "true"
Header always set Access-Control-Max-Age "86400"
</VirtualHost>
.htaccess Configuration
# Enable mod_rewrite
RewriteEngine On
# Handle preflight
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
# CORS headers
Header always set Access-Control-Allow-Origin "https://app.example.com"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
Header always set Access-Control-Allow-Credentials "true"
Header always set Access-Control-Max-Age "86400"
A common Apache pitfall: if your backend framework (Laravel, Django, etc.) also sets CORS headers, you end up with duplicate headers. The browser receives two Access-Control-Allow-Origin values and rejects the response. Set CORS in one place only, either Apache or your application, not both.
Step 5: Fix for Node.js / Express
Express has two approaches. The cors npm package handles everything automatically, or you can write a manual middleware. I recommend the package for most projects.
Using the cors Package
const express = require('express');
const cors = require('cors');
const app = express();
// Option 1: Allow a specific origin with credentials
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
}));
// Option 2: Dynamic origin allowlist
const allowedOrigins = [
'https://app.example.com',
'https://staging.example.com'
];
app.use(cors({
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
Manual Middleware
app.use((req, res, next) => {
const allowedOrigins = ['https://app.example.com'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', '86400');
// Handle preflight
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. The CORS specification explicitly forbids this combination, and browsers will silently reject the response. If your API uses cookies, tokens in headers, or TLS client certificates, you must echo back the specific requesting origin instead of using the wildcard. This is the single most common CORS misconfiguration I encounter in production, and it causes requests to fail with zero useful error messages. See our guide on CORS misconfiguration as a security risk for more on why this matters.
Is Your API Leaking Data Through CORS?
Misconfigured CORS headers can expose your API to unauthorized origins. Run a free check to see if your domain has CORS, header, or exposure issues.
Check Your Domain FreeStep 6: Fix for AWS API Gateway
API Gateway has built-in CORS support, but it only works if you configure it at the right level. The most common problem is enabling CORS on the resource but forgetting to deploy the changes, or not realizing that Lambda proxy integration requires you to return CORS headers from your Lambda function too.
Console Method
In the API Gateway console, select your resource, click "Enable CORS" from the Actions menu, specify your allowed origin, methods, and headers, then redeploy the API stage. This creates an OPTIONS mock integration automatically.
SAM / CloudFormation Template
Resources:
ApiGateway:
Type: AWS::Serverless::Api
Properties:
StageName: prod
Cors:
AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization'"
AllowOrigin: "'https://app.example.com'"
AllowCredentials: true
MaxAge: "'86400'"
MyFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs20.x
Events:
Api:
Type: Api
Properties:
RestApiId: !Ref ApiGateway
Path: /data
Method: ANY
With Lambda proxy integration, your Lambda function must also return CORS headers in its response. API Gateway does not add them automatically when using proxy mode.
exports.handler = async (event) => {
const headers = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Credentials': true,
};
return {
statusCode: 200,
headers: headers,
body: JSON.stringify({ message: 'Success' }),
};
};
Step 7: Fix for Cloudflare Workers
Cloudflare Workers sit in front of your origin server, making them an ideal place to handle CORS at the edge. Here is a complete Worker that handles preflight and adds CORS headers to actual responses.
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging.example.com',
];
const CORS_HEADERS = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
'Access-Control-Max-Age': '86400',
};
function getCorsHeaders(request) {
const origin = request.headers.get('Origin');
if (ALLOWED_ORIGINS.includes(origin)) {
return {
...CORS_HEADERS,
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
};
}
return {};
}
export default {
async fetch(request) {
// Handle preflight
if (request.method === 'OPTIONS') {
const headers = getCorsHeaders(request);
if (Object.keys(headers).length === 0) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 204, headers });
}
// Fetch from origin
const response = await fetch(request);
const newResponse = new Response(response.body, response);
// Add CORS headers to actual response
const corsHeaders = getCorsHeaders(request);
for (const [key, value] of Object.entries(corsHeaders)) {
newResponse.headers.set(key, value);
}
return newResponse;
},
};
This approach validates the origin against an allowlist, returns a 403 for unknown origins on preflight, and adds CORS headers to the proxied response. It also avoids the wildcard origin entirely.
Step 8: Fix for CDN and Proxy Layer Issues
When you have a CDN or reverse proxy (Cloudflare, Varnish, CloudFront) sitting in front of your origin, CORS headers can get stripped, cached incorrectly, or duplicated. This is one of the hardest CORS bugs to diagnose because the server config looks correct, but the browser still fails.
The Vary: Origin Problem
If your server returns different Access-Control-Allow-Origin values depending on the requesting origin, you must include Vary: Origin in the response. Without it, a CDN may cache the response for https://app.example.com and serve that cached response (with the wrong origin header) to a request from https://staging.example.com.
# Always include Vary: Origin when you echo back the origin
Vary: Origin
Cloudflare Stripping Headers
Cloudflare does not strip CORS headers by default, but certain Page Rules or Transform Rules can modify response headers. If your origin returns correct CORS headers and the browser still fails, check your Cloudflare dashboard for any response header modifications. Use the cf-ray header and the Cloudflare Trace tool to verify the response passes through unchanged.
Varnish Cache
Varnish requires special VCL to handle CORS correctly. You must include Origin in the hash to ensure different origins get different cached responses.
sub vcl_hash {
# Include Origin header in cache key for CORS
if (req.http.Origin) {
hash_data(req.http.Origin);
}
}
sub vcl_deliver {
# Ensure Vary: Origin is always present
if (resp.http.Access-Control-Allow-Origin) {
set resp.http.Vary = "Origin";
}
}
Doubled Headers
If both your proxy layer and your application set CORS headers, the browser receives duplicate values. For example, two Access-Control-Allow-Origin headers in one response. Browsers reject this because the spec requires exactly one value. The fix is simple: set CORS headers in exactly one layer, not multiple.
CORS Header Reference
| Header | Valid Values | Preflight | Actual | Common Mistake |
|---|---|---|---|---|
Access-Control-Allow-Origin |
Specific origin or * |
Required | Required | Using * with credentials |
Access-Control-Allow-Methods |
Comma-separated methods | Required | Not needed | Forgetting PUT/PATCH/DELETE |
Access-Control-Allow-Headers |
Comma-separated headers | Required | Not needed | Omitting Authorization or Content-Type |
Access-Control-Allow-Credentials |
true (only value) |
If using credentials | If using credentials | Setting it without echoing specific origin |
Access-Control-Max-Age |
Seconds (e.g., 86400) |
Optional | Not needed | Setting too high, stale preflight cache |
Access-Control-Expose-Headers |
Comma-separated headers | Not needed | Optional | Forgetting it, then wondering why JS cannot read custom response headers |
Security Considerations
CORS exists for a reason. It prevents malicious websites from making authenticated requests to your API on behalf of your users. Before you "fix" CORS by allowing everything, understand the security implications.
Why CORS Exists
Without CORS, any website could make requests to your API using your user's cookies. A malicious site at evil.com could call https://yourbank.com/api/transfer with the user's session cookie, and the bank's server would process it. CORS stops this by requiring the server to explicitly opt in to cross-origin requests. Read our detailed breakdown of CORS misconfiguration risks for real-world attack scenarios.
The Danger of Wildcard Origins
Setting Access-Control-Allow-Origin: * tells the browser that any website on the internet can read responses from your API. For public, unauthenticated APIs (weather data, public datasets), this is fine. For anything involving user data, authentication, or sensitive operations, it is a serious vulnerability. An attacker could host a page that calls your API and reads the response, exfiltrating user data.
Origin Allowlist Pattern
The secure approach is to maintain an allowlist of permitted origins and validate the incoming Origin header against it. Never use regex matching on the origin without careful testing, as patterns like /example\.com$/ would also match evil-example.com.
// Secure origin validation
const ALLOWED = new Set([
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com',
]);
function isOriginAllowed(origin) {
return ALLOWED.has(origin);
}
Credential Handling
When your frontend uses credentials: 'include' in fetch (or withCredentials: true in XMLHttpRequest), the server must set Access-Control-Allow-Credentials: true and must NOT use the wildcard origin. The browser enforces both rules. If either is violated, the request fails silently. Build your Content Security Policy alongside your CORS config to create a layered defense.
Scan Your Security Headers
CORS is just one piece of the security header puzzle. Check your full header configuration, exposed files, SSL, and DNS in one scan.
Run Free Security ScanCommon Mistakes (and How to Avoid Them)
- Wildcard with credentials. You set
Access-Control-Allow-Origin: *andAccess-Control-Allow-Credentials: true. The browser silently rejects this. Fix: echo back the specific origin from the request instead of using the wildcard. - Missing OPTIONS handler. Your server returns
405 Method Not Allowedor404 Not Foundfor OPTIONS requests. The browser never gets the CORS headers and blocks the actual request. Fix: add an explicit OPTIONS route or handler that returns 204 with CORS headers. - Forgetting Access-Control-Allow-Headers. Your preflight response includes the origin and methods but not the allowed headers. The browser checks all three. If your frontend sends
Authorizationand the server does not list it inAccess-Control-Allow-Headers, the request is blocked. Fix: list every custom header your frontend sends. - Caching without Vary: Origin. Your CDN caches the first CORS response (for origin A) and serves it to requests from origin B. Origin B gets the wrong
Access-Control-Allow-Originvalue and the browser blocks it. Fix: always includeVary: Originwhen you echo back the requesting origin. - Doubled CORS headers. Your reverse proxy (Nginx, Apache) and your application framework (Express, Django, Laravel) both add CORS headers. The browser receives two
Access-Control-Allow-Originvalues and rejects the response, even if both values are correct. Fix: set CORS in one layer only. If your framework handles it, remove it from the proxy config, or vice versa.
Frequently Asked Questions
Why does CORS only affect browsers?
CORS is a browser-enforced policy. When you make a request from a server (Node.js, Python, curl, Postman), there is no browser to enforce the same-origin policy. The server-to-server request succeeds because CORS headers are only checked by the browser before exposing the response to JavaScript. This is why your API "works in Postman but not in the browser." It is not a server bug, it is a browser security feature doing its job. Your security headers configuration should account for this difference.
What triggers a preflight request?
Any cross-origin request that is not "simple" triggers a preflight. In practice, these conditions cause a preflight: setting Content-Type to application/json, sending an Authorization header, using HTTP methods other than GET/HEAD/POST, sending any custom headers (X-anything), or using ReadableStream in the request body. If you want to avoid preflights entirely, you would need to stick to GET/POST with form-encoded data and no custom headers, which is impractical for modern APIs.
Can I disable CORS?
You cannot disable CORS on the browser side in production. Browser extensions like "CORS Unblock" exist for development, but they modify the browser behavior locally and are not a real solution. The correct approach is always to configure your server to return the proper headers. For local development, you can also use a proxy in your development server config (webpack-dev-server, Vite, Next.js) to avoid cross-origin requests entirely during development.
Why does my API work in Postman but not in the browser?
Postman is not a browser. It does not enforce the same-origin policy and does not send preflight OPTIONS requests. When Postman makes a request, it sends it directly to the server and shows you the raw response. Browsers add a layer of security between the server response and your JavaScript. The server might return a perfectly valid response, but if it is missing CORS headers, the browser will refuse to let your JavaScript read it. The response technically "arrives" but is blocked before your code can access it.
How do I handle multiple allowed origins?
The Access-Control-Allow-Origin header only accepts a single origin or the wildcard *. You cannot list multiple origins in one header value. The standard approach is to read the incoming Origin header from the request, check it against your allowlist, and if it matches, echo that specific origin back in the response. This way, each response contains the correct single origin for that particular request. Remember to include Vary: Origin so that CDN and proxy caches store separate responses for each origin.
The Bottom Line
CORS preflight errors are one of the most frustrating issues in web development because the fix is always the same concept (return the right headers on OPTIONS), but the implementation differs for every server, framework, and proxy layer. Start by confirming the issue in DevTools. Then add the three required headers to your OPTIONS response. Test with a real browser, not Postman or curl. And never use the wildcard origin on an API that handles credentials or sensitive data.
If you are building a new API, set up CORS correctly from the start. If you are debugging an existing one, check every layer in the chain (CDN, proxy, application) because headers can be added, stripped, or duplicated at any point. The configs in this guide cover the five most common server environments. Copy the one for your stack, adjust the origins and headers to match your setup, and redeploy.
Related reading: CORS Misconfiguration as a Security Risk, Website Security Headers Guide. Related tools: Exposure Checker, CSP Builder, and 70+ more free tools.
Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.