CORS, Preflight, and the Five-Minute Debugging Flow
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:
- Simple requests are GET, HEAD, or POST with very limited headers and content-types. The browser sends them immediately and reads the response only if the server includes the right CORS headers.
- Preflighted requests are everything else: PUT, DELETE, PATCH, POST with JSON content-type, any custom header. The browser sends an OPTIONS request first asking the server "would you accept this request if I sent it?" If the server says yes (with the right headers), the browser sends the actual request.
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):
Origin: https://app.example.com: who is making the request.Access-Control-Request-Method: PUT(preflight only): which method the real request will use.Access-Control-Request-Headers: X-Custom-Header(preflight only): which non-standard headers.
The response side (set by the server, which you must configure):
Access-Control-Allow-Origin: the origin that is allowed. Use the literal origin string (not*) when credentials are involved.Access-Control-Allow-Methods(preflight): which methods the server permits.Access-Control-Allow-Headers(preflight): which non-standard headers the server permits.Access-Control-Allow-Credentials: true: required if the request sends cookies. Cannot be combined withAccess-Control-Allow-Origin: *.Access-Control-Max-Age: 600: cache the preflight response for this many seconds, so the browser does not re-preflight every request.
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:
- The server echoes back
http://app.example.combut the request is fromhttps://app.example.com. Protocol mismatch. - The server returns
*but the request includes credentials. Browser rejects this. - The server returns a trailing slash.
https://app.example.com/is not the same origin string ashttps://app.example.com. - The server returns multiple Access-Control-Allow-Origin headers concatenated. Browser rejects this.
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:
- Network failure mid-request. If the server crashes after sending the preflight response, the browser reports a CORS-shaped error on the real request even though the actual cause is a 500.
- Mixed content. An HTTPS page calling an HTTP endpoint will fail. The error sometimes looks like CORS.
- SSL certificate problems. Cert mismatches show up as opaque CORS-adjacent errors in some browsers.
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:
- curl-to-code converts curl commands to fetch/axios/Python/Go snippets, which is useful when you can curl an endpoint but cannot fetch it. Side-by-side comparison usually exposes the header mismatch.
- API tester lets you fire requests directly from your browser with custom headers, which is sometimes faster than rebuilding the failing request in your app.