CORS Internals & Preflight
Why Your Fetch Fails (And Why That's a Feature)
You've hit a CORS error. The response was 200 OK. The data was right there in the network tab. But the browser refused to let your JavaScript read it. This feels broken — but it's actually the browser protecting your users.
Here's what most developers get wrong: CORS errors don't come from the server rejecting your request. The server handled it fine. The browser blocked your JavaScript from reading the response because the server didn't explicitly say "yes, this origin is allowed to see my data."
Imagine you send a letter to a company (the server). They receive it, process it, and write a reply. But before the mailman (browser) hands you the reply, he checks if the company wrote "approved for delivery to this address" on the envelope. If that note is missing, the mailman shreds the letter — even though the company already wrote the response. The server did the work. The browser is the one enforcing the access control. CORS headers are the "approved for delivery" note.
The Same-Origin Policy: Why CORS Exists
Before CORS, the Same-Origin Policy (SOP) was the only access control. It's simple: JavaScript on https://myapp.com can only read responses from https://myapp.com. Any other origin is blocked.
An "origin" is the combination of scheme + host + port:
https://myapp.com:443 → origin: https://myapp.com
http://myapp.com:80 → different origin (http vs https)
https://api.myapp.com → different origin (different host)
https://myapp.com:8080 → different origin (different port)
SOP prevents a malicious page from reading your bank's API responses while you're logged in. Without it, any website you visit could make authenticated requests to your bank (your cookies go along for the ride) and read the account data.
CORS is the opt-in mechanism that lets servers relax this restriction: "I know this request is cross-origin, and I'm okay with that specific origin reading my response."
Simple Requests vs Preflight Requests
Not all cross-origin requests are treated equally. The browser divides them into two categories based on what you're asking to do.
Simple Requests
A request is "simple" (the spec calls it a "CORS-safelisted request") if it meets ALL of these conditions:
- Method: GET, HEAD, or POST only
- Headers: Only
Accept,Accept-Language,Content-Language,Content-Type,Range - Content-Type (if present): Only
application/x-www-form-urlencoded,multipart/form-data, ortext/plain - No ReadableStream body
- No event listeners on XMLHttpRequest.upload
Simple requests go directly to the server. The browser sends the request, checks the response headers, and decides whether to expose the response to JavaScript.
GET /api/public HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json
{"data": "public info"}
Preflight Requests
Anything that doesn't qualify as "simple" triggers a preflight — an OPTIONS request the browser sends before the actual request. This is the browser asking: "Hey server, would you accept this kind of request from this origin?"
Common triggers for preflight:
- Method is PUT, PATCH, or DELETE
- Custom headers like
Authorization,X-Request-ID - Content-Type is
application/json - Request includes credentials in certain configurations
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
If the preflight passes, the browser sends the actual PUT request. If it fails, the actual request never leaves the browser.
Every Access-Control Header Explained
Request Headers (sent by the browser)
| Header | Purpose | Example |
|---|---|---|
Origin | The origin making the request | https://myapp.com |
Access-Control-Request-Method | Method the actual request will use (preflight only) | PUT |
Access-Control-Request-Headers | Custom headers the actual request will send (preflight only) | Authorization, Content-Type |
Response Headers (sent by the server)
| Header | Purpose | Example |
|---|---|---|
Access-Control-Allow-Origin | Which origin(s) can read the response | https://myapp.com or * |
Access-Control-Allow-Methods | Allowed methods beyond simple ones | GET, PUT, DELETE |
Access-Control-Allow-Headers | Allowed custom headers | Authorization, X-Request-ID |
Access-Control-Expose-Headers | Response headers JS can read | X-Total-Count, X-Request-ID |
Access-Control-Max-Age | How long to cache preflight results (seconds) | 86400 |
Access-Control-Allow-Credentials | Whether cookies/auth are allowed | true |
The Expose-Headers Gotcha
By default, JavaScript can only read these response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Pragma. Everything else is hidden unless the server explicitly exposes it:
Access-Control-Expose-Headers: X-Total-Count, X-Request-ID
This catches a lot of developers off guard. Your API returns a custom header, you can see it in DevTools, but response.headers.get('X-Total-Count') returns null. The header is there — the browser just won't let your JS read it without explicit permission.
Credentials Mode: Cookies and Cross-Origin
By default, cross-origin fetch requests don't send cookies. This is a deliberate security choice — you don't want every random website sending your bank's session cookie along with its requests.
To send cookies cross-origin, both sides must opt in:
Client side:
fetch('https://api.example.com/me', {
credentials: 'include' // send cookies
})
Server side:
Access-Control-Allow-Origin: https://myapp.com // MUST be specific, NOT *
Access-Control-Allow-Credentials: true
When credentials: 'include' is set, the server cannot use Access-Control-Allow-Origin: *. It must specify the exact origin. This is a security measure — wildcard with credentials would let any website make authenticated requests to your API. Many developers hit this wall and "fix" it by dynamically reflecting the Origin header back as Access-Control-Allow-Origin. This is equivalent to using * and defeats the purpose entirely. Always validate the origin against an explicit allowlist.
The Three Credentials Modes
// "omit" — never send cookies (even same-origin)
fetch(url, { credentials: 'omit' })
// "same-origin" — send cookies only for same-origin requests (default)
fetch(url, { credentials: 'same-origin' })
// "include" — send cookies for all requests, including cross-origin
fetch(url, { credentials: 'include' })
Preflight Caching: The Performance Angle
Every preflight request adds latency — it's an extra round trip before the actual request. The Access-Control-Max-Age header tells the browser to cache the preflight result:
Access-Control-Max-Age: 86400 // cache for 24 hours
Without this header, browsers use their own defaults (Chrome caches for 2 hours, Firefox for 24 hours). Set it explicitly to avoid per-browser surprises.
The cache key is (origin, URL, credentials mode). A request from a different origin or with different credentials triggers a fresh preflight even if the same URL was preflighted before.
Why preflight exists at all
Preflight seems wasteful — why not just send the request and check headers on the response? The answer is backward compatibility and side effects.
Before CORS existed, servers assumed that browsers would only send certain types of requests (forms, image loads, script tags). A server behind a firewall might accept a DELETE request because "only our internal admin panel sends DELETE, so it must be trusted."
Preflight protects these legacy servers. By asking permission first with an OPTIONS request, the browser ensures the server explicitly understands and accepts the cross-origin request. Without preflight, a malicious page could send a DELETE to an internal server that has no idea cross-origin DELETE requests are possible — and the deletion would happen before CORS headers are even checked.
Simple requests don't need preflight because forms could already send GET/POST with simple content types before CORS existed. No new server behavior is exposed by allowing those through.
Production Scenario: Configuring CORS for a Multi-Tenant API
Your API serves multiple frontend apps across different domains. Here's a production-grade CORS configuration pattern in Node.js:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com',
])
function corsMiddleware(req, res, next) {
const origin = req.headers.origin
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Vary', 'Origin')
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
res.writeHead(204)
res.end()
return
}
next()
}
Key details:
- Explicit origin allowlist — no wildcard, no reflected origin
Vary: Origin— critical for CDN/proxy caches. Without it, a cached response withAllow-Origin: https://app.example.commight be served toadmin.example.com, breaking CORS- 204 for OPTIONS — no body needed for preflight responses
- Max-Age: 86400 — reduces preflight overhead for repeated requests
| What developers do | What they should do |
|---|---|
| Setting Access-Control-Allow-Origin to * while also using credentials The CORS spec forbids wildcard origin with credentials. Browsers reject the response. You must send the specific requesting origin (validated against an allowlist). | Specifying the exact allowed origin when credentials are included |
| Reflecting the Origin header back without validation Reflecting any origin is equivalent to using a wildcard — any website can make authenticated requests to your API. Always validate against a hardcoded set of allowed origins. | Checking the Origin against an explicit allowlist of trusted domains |
| Forgetting the Vary: Origin header when origin-specific responses are served Without Vary: Origin, a CDN or browser HTTP cache might serve a response cached for origin A to a request from origin B, causing a CORS failure. Vary tells caches that the response depends on the Origin header. | Always including Vary: Origin when Access-Control-Allow-Origin is not a wildcard |
Challenge: Debug This CORS Configuration
Try to solve it before peeking at the answer.
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && origin.endsWith('.example.com')) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Allow-Methods', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
next()
})- 1CORS is enforced by the browser, not the server — the server cooperates by sending headers, the browser decides whether JS can read the response
- 2Never reflect the Origin header without validation — use an explicit Set of allowed origins
- 3Always set Vary: Origin when dynamically choosing the allowed origin — cache mismatches break CORS silently
- 4Credentials require a specific origin (not wildcard) and Access-Control-Allow-Credentials: true on both sides
- 5Set Access-Control-Max-Age to cache preflight responses — every uncached preflight adds a full round trip of latency
- 6Preflight exists to protect legacy servers from unexpected cross-origin side effects, not to authenticate requests