CORS Preflight Explained
Why an extra OPTIONS request appears before your real one, what triggers it, and how to stop it doubling your latency.
Before certain cross-origin requests, the browser sends an OPTIONS request to ask permission. That is the preflight. If the answer is not satisfactory, the real request is never sent.
What triggers a preflight
A request avoids preflight only if it is simple — and "simple" is a short list:
- Method is
GET,HEADorPOST, and - The only headers you set are on the safelist (
Accept,Accept-Language,Content-Language,Content-Type), and Content-Typeistext/plain,multipart/form-dataorapplication/x-www-form-urlencoded.
So almost every real API call preflights, because Content-Type: application/json alone is enough — and so is Authorization, and so is PUT, PATCH or DELETE.
The exchange
OPTIONS /api/orders HTTP/1.1
Origin: https://abc.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://abc.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400
Vary: OriginThe headers, one by one
Access-Control-Allow-Origin— a single exact origin, or*. Never a list.Access-Control-Allow-Methods— must include the method being requested.Access-Control-Allow-Headers— must include every non-safelisted header you send. Missingauthorizationhere is the single most common CORS failure.Access-Control-Max-Age— how long the browser may cache this answer. Set it: without it, every request pays for two round-trips.Access-Control-Expose-Headers— response headers your JavaScript is allowed to read. Without it,res.headers.get('X-Total-Count')returnsnulleven though the header is right there.Vary: Origin— essential if anything caches your responses, or a CDN will serve the allow-origin forabc.comto a request from somewhere else.
Preflights must be unauthenticated
The browser sends OPTIONS without credentials — no cookie, no Authorization. If your auth middleware runs before your CORS handler, it returns 401 to the preflight and the real request never happens. Mount CORS first, and let OPTIONS through.
Example
# Simulate what the browser sends before a real POST
curl -i -X OPTIONS https://dfg.com/api/orders \
-H "Origin: https://abc.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization, content-type"
# A correct answer
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://abc.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: authorization, content-type, x-xsrf-token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin
# 401 here means your auth middleware is running before your CORS middleware.When to use it
- An API doubles its p50 latency for browser clients until Access-Control-Max-Age is set and preflights start being cached for a day.
- A pagination header is invisible to the frontend until it is listed in Access-Control-Expose-Headers.
- A CDN serves a cached Access-Control-Allow-Origin for the wrong origin because the response was missing Vary: Origin.
More examples
CORS first, auth second
The cors package answers OPTIONS itself and ends the request, which is exactly why it has to be mounted before any authentication.
import express from 'express';
import cors from 'cors';
const app = express();
const ALLOWED = new Set(['https://abc.com', 'https://www.abc.com']);
// ✅ CORS is mounted BEFORE anything that can reject a request.
app.use(cors({
origin(origin, cb) {
// No Origin header at all = curl / server-to-server → let it through.
if (!origin) return cb(null, true);
return ALLOWED.has(origin) ? cb(null, true) : cb(new Error('origin_not_allowed'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-XSRF-TOKEN'],
exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
maxAge: 86400, // cache the preflight for a day
}));
app.use(express.json());
app.use('/api', bearerAuth); // ← runs after CORS, so OPTIONS is never 401'd
// ❌ The bug: this ordering makes every preflight fail
// app.use('/api', bearerAuth);
// app.use(cors({ ... }));Hand-rolled, so you can see every header
Setting Vary: Origin unconditionally — including on rejected origins — is what stops a shared cache from leaking one tenant's allow-origin to another.
const ALLOWED = new Set(['https://abc.com']);
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin); // echo the exact origin
res.set('Access-Control-Allow-Credentials', 'true');
}
// Always vary on Origin — even when you did not allow it — so caches and
// CDNs never reuse one origin's response for another.
res.set('Vary', 'Origin');
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-XSRF-TOKEN');
res.set('Access-Control-Max-Age', '86400');
return res.status(204).end(); // answer and stop: no auth, no body
}
next();
});
Discussion