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, HEAD or POST, and
  • The only headers you set are on the safelist (Accept, Accept-Language, Content-Language, Content-Type), and
  • Content-Type is text/plain, multipart/form-data or application/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: Origin

The 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. Missing authorization here 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') returns null even though the header is right there.
  • Vary: Origin — essential if anything caches your responses, or a CDN will serve the allow-origin for abc.com to 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

Example · bash
# 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.

Example · javascript
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.

Example · javascript
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

  • Be the first to comment on this lesson.