← Back to Blog

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, or multipart/form-data
  • No custom headers beyond Accept, Accept-Language, Content-Language
  • No ReadableStream in 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/json is the most common trigger

This is why your simple form POST works cross origin but your JSON API call does not: Content-Type: application/json alone 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 Formatter

Caching 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-Origin must 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 rejects Access-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's Access-Control-Allow-Headers must 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:

  1. Open DevTools → Network tab → find the OPTIONS request
  2. Check the Response Headers - is Access-Control-Allow-Origin present?
  3. Does the allowed origin exactly match your page's origin (including protocol and port)?
  4. Check the Console for the specific error: it usually states which header is missing
  5. 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

UK
Written by Usman Khan
DevOps Engineer | MSc Cybersecurity | CEH | AWS Solutions Architect

Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.