CORS Preflight Requests: What They Are & How to Handle Them
If you have ever seen a mysterious OPTIONS request appear in your browser's network tab before your actual API call, you have encountered a CORS preflight. Understanding exactly when and why it happens - and how to respond to it correctly - is essential for any developer building web APIs.
What is CORS and Why Does the Browser Enforce It?
CORS (Cross-Origin Resource Sharing) is a browser security mechanism that restricts web pages from making HTTP requests to a different origin (domain, port, or protocol) than the one that served the page. Without CORS, a malicious website could use your logged-in browser session to make API calls to your bank's API on your behalf.
The browser enforces CORS on the client side. Servers must explicitly declare which origins are permitted to access them. This declaration happens through HTTP response headers like Access-Control-Allow-Origin.
CORS does not apply to:
- Server-to-server requests (curl, Node.js backend, etc.) - only browsers enforce CORS
- Same-origin requests
- Certain "simple" cross origin requests that meet specific criteria
Simple Requests vs. Preflighted Requests
Not every cross origin request triggers a preflight. The browser classifies requests into two categories:
Simple Requests (no preflight)
A request is "simple" (and sent directly without preflight) if it meets ALL of these conditions:
- Method is GET, HEAD, or POST
- Content-Type is
text/plain,application/x-www-form-urlencoded, ormultipart/form-data - No custom headers beyond
Accept,Accept-Language,Content-Language - No
ReadableStreamin the request body
Preflighted Requests (OPTIONS sent first)
A preflight is triggered when any of these conditions are true:
- Non-simple methods: PUT, DELETE, PATCH, or any method other than GET, HEAD, POST
- Custom headers:
Authorization,Content-Type: application/json,X-Custom-Header, or any header not in the "simple" allowlist - Content-Type other than the three simple types:
application/jsonis the most common trigger
This is why your simple form POST works cross origin but your JSON API call does not:
Content-Type: application/jsonalone triggers a preflight.
What the Preflight Request Looks Like
When the browser decides a preflight is required, it sends an OPTIONS request to the URL before the actual request. This OPTIONS request contains two key headers that describe the intended actual request:
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
The browser is asking: "I want to send a POST request with Content-Type and Authorization headers. Is that allowed?"
What the Server Must Respond
The server must respond to the OPTIONS request with the appropriate CORS headers. If any required header is missing, the browser blocks the actual request:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Breaking down each header:
- Access-Control-Allow-Origin: The specific origin(s) allowed. Use
*for public APIs, but*cannot be used with credentials. - Access-Control-Allow-Methods: HTTP methods the server accepts from this origin.
- Access-Control-Allow-Headers: Must include every header listed in the request's
Access-Control-Request-Headers. - Access-Control-Max-Age: How long (in seconds) the browser can cache this preflight result. This is critical for performance.
Step-by-Step: Implementing CORS in Common Frameworks
Express.js (Node.js)
npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
// Allow all origins (public API)
app.use(cors());
// Specific origins (recommended for production)
app.use(cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400 // cache preflight for 24 hours
}));
// Handle preflight explicitly for all routes
app.options('*', cors());
Manual CORS Middleware (Express.js)
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, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
// Respond to preflight immediately
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
Nginx
server {
location /api/ {
# Handle preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
return 204;
}
# Add CORS headers to all responses
add_header Access-Control-Allow-Origin "https://app.example.com" always;
proxy_pass http://backend;
}
}
Apache (.htaccess)
Header always set Access-Control-Allow-Origin "https://app.example.com"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization"
Header always set Access-Control-Max-Age "86400"
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
Validate API Responses Instantly
Once your CORS is fixed and your API is responding correctly, use our free JSON Formatter to validate and explore the response data.
Open JSON FormatterCaching Preflight with Access-Control-Max-Age
Every preflight adds a round trip before the actual request. For high-frequency API calls, this doubles your latency. The Access-Control-Max-Age header tells the browser how many seconds to cache the preflight result. During this time, the browser skips the OPTIONS request and sends the actual request directly.
Access-Control-Max-Age: 86400 // cache for 24 hours
Browser limits: Chrome caps this at 2 hours (7200 seconds). Firefox allows up to 24 hours. Setting higher values is safe - browsers will cap them automatically.
CORS with Credentials (Cookies and Authorization)
When your request includes credentials (cookies, HTTP authentication, or client side certificates), the rules change:
- The server must respond with
Access-Control-Allow-Credentials: true Access-Control-Allow-Originmust be an explicit origin, not*- The fetch request must include
credentials: 'include'
// Frontend fetch with credentials
const response = await fetch('https://api.example.com/profile', {
credentials: 'include', // send cookies cross origin
headers: { 'Authorization': 'Bearer token' }
});
// Server response headers required
Access-Control-Allow-Origin: https://app.example.com // NOT *
Access-Control-Allow-Credentials: true
Common CORS Mistakes and How to Fix Them
- Using
*with credentials: The browser rejectsAccess-Control-Allow-Origin: *when credentials are included. Use an explicit origin. - Missing the Authorization header in Allow-Headers: If your fetch sends
Authorization, the server'sAccess-Control-Allow-Headersmust explicitly list it. - Server not handling OPTIONS at all: Some backends return 405 Method Not Allowed for OPTIONS. You must add explicit handling for the OPTIONS method.
- Wildcard in Allow-Headers not working: Some servers set
Access-Control-Allow-Headers: *, which is not supported in all browsers. List headers explicitly. - CORS configured in app but not in load balancer: If Nginx or a CDN terminates connections before your app, CORS headers must be set at the proxy level too.
- Duplicate CORS headers: If your proxy adds CORS headers and your app also adds them, the browser sees duplicate headers and rejects the response.
Debugging CORS Errors
The browser's CORS error messages can be cryptic. Here is a systematic approach:
- Open DevTools → Network tab → find the OPTIONS request
- Check the Response Headers - is
Access-Control-Allow-Originpresent? - Does the allowed origin exactly match your page's origin (including protocol and port)?
- Check the Console for the specific error: it usually states which header is missing
- If no OPTIONS request appears at all, check if the request was cached (disable cache in DevTools)
# Test CORS with curl (simulates a preflight)
curl -v -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"
FAQ
Why does my request work in curl but fail in the browser?
curl does not enforce CORS - it is not a browser. CORS is a browser-side security mechanism. When curl succeeds but the browser fails, the server is responding correctly to the actual request but not sending the required CORS headers. The fix is always server side: add the correct Access-Control-Allow-* response headers.
Does CORS protect the server or the browser user?
It protects the browser user. CORS prevents malicious websites from using your authenticated browser session to make requests to other origins on your behalf. An attacker who controls a server does not need CORS because they are not constrained by browser rules. CORS is meaningless for server-to-server communication.
What is the difference between Access-Control-Allow-Origin and Content-Security-Policy?
They are separate mechanisms. Access-Control-Allow-Origin controls which origins can read responses to cross origin fetch/XHR requests. Content-Security-Policy controls what resources (scripts, images, fonts, etc.) the page itself is allowed to load. You need both for a complete security posture.
Can I avoid preflight entirely for a POST with JSON?
The only way to avoid preflight for a POST with a JSON body is to change the Content-Type to text/plain and manually parse the JSON on the server. This is a hack and breaks API conventions. A better approach is to set Access-Control-Max-Age to 86400 seconds so the preflight is only sent once per day per browser.
What HTTP status code should the OPTIONS response return?
Either 200 OK or 204 No Content. The 204 is preferred because it has no body, keeping the response small. Some older frameworks return 200. Both are accepted by browsers. Never return 4xx for a valid OPTIONS preflight.
Use our free tool here → JSON Formatter & Validator
Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.