CORS, Preflight, and the Five-Minute Debugging Flow

By Λ · May 18, 2026 · 7 min read

The number of engineering hours I have personally watched evaporate into CORS debugging is staggering. The error messages are oblique, the underlying protocol is older than half the team, and the documentation on MDN is good but assumes you already know what a preflight request is. This post is the explanation and the diagnostic flow I use, which usually gets me from "fetch is failing" to "fix deployed" in under five minutes.

The two-sentence explanation of CORS

By default, a browser will not let JavaScript on origin A read responses from origin B. CORS is the mechanism by which origin B says "I am OK with origin A reading my responses." Everything else about CORS is implementation detail of that single sentence.

The "default" part is the security model. Without it, any malicious site could JavaScript-call your bank's API using your browser's cookies. CORS is the bank saying "the only origins I trust are app.bank.com and mobile.bank.com, so block reads from evil.example."

The simple-vs-preflight split

The browser splits cross-origin requests into two categories:

The preflight is invisible in application code. You call fetch(), the browser secretly fires an OPTIONS request first, and only if that succeeds does your actual fetch run. When CORS fails, half the time the failure is in the preflight, not the real request, and the error message in the browser console will not tell you which.

The headers that matter

The request side (set by the browser, not your code):

The response side (set by the server, which you must configure):

The five-minute debugging flow

When CORS fails, the browser console message is usually unhelpful. Here is the systematic check I run:

Step 1: identify the failing request in DevTools

Open the Network tab. Find the failed request. Check whether it is an OPTIONS request (preflight) or the actual request (GET/POST/etc.). This single distinction tells you whether the problem is in the preflight or the real call.

Step 2: re-run the request with curl, bypassing the browser

This is the most underused debugging tool. The curl-to-code tool on this site can help you build the right command. The key flag is -H "Origin: https://app.example.com" to mimic the browser:

# Test the preflight
curl -i -X OPTIONS https://api.example.com/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type"

# Test the actual request
curl -i -X PUT https://api.example.com/users/42 \
  -H "Origin: https://app.example.com" \
  -H "Content-Type: application/json" \
  -d '{"name":"new"}'

If curl shows the correct CORS headers in the response, the server is fine and the bug is somewhere in browser cache or proxy land. If curl shows missing or wrong CORS headers, you have a server-side problem.

Step 3: check the exact value of Access-Control-Allow-Origin

Common mistakes:

Step 4: check the preflight cache

If you fixed the server but the browser still fails, the browser may be using a cached negative preflight result. Open DevTools, check "Disable cache", and reload. Or just wait Access-Control-Max-Age seconds and try again.

Step 5: verify the request actually has the Origin header

Same-origin requests do not include an Origin header. If your code accidentally calls a path that resolves to the same origin (because you forgot to use the full URL), the browser will not send an Origin header and the server will not include CORS headers in the response. This produces a confusing failure mode where your fetch works locally but fails in production because the URLs differ.

The "I just want to allow everything" footgun

Many tutorials suggest Access-Control-Allow-Origin: * as a quick fix. It works for public APIs without credentials. For anything that uses cookies or session tokens, it does not work at all: the browser explicitly refuses to send cookies cross-origin to a wildcard, and the security implications would be enormous if it did.

The correct production pattern: keep an allowlist of origins server-side, check the incoming Origin header against the allowlist, and echo back the matching origin in Access-Control-Allow-Origin. Plus Vary: Origin so caches do not serve the wrong header.

// Express middleware sketch
const ALLOWED = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (ALLOWED.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Vary', 'Origin');
    res.set('Access-Control-Allow-Credentials', 'true');
  }
  if (req.method === 'OPTIONS') {
    res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
    res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.set('Access-Control-Max-Age', '600');
    return res.sendStatus(204);
  }
  next();
});

The classes of "CORS error" that are not actually CORS

Three failure modes that the browser reports as CORS errors but are actually something else:

When in doubt, do step 2 above (curl with -i). It will reveal whether the server is actually responding correctly.

Where the tools help

Two tools on this site that come up in CORS debugging:

Related