Skip to content

CORS Internals & Preflight

expert20 min read

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."

Mental Model

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."

Quiz
An application at https://app.example.com makes a fetch to https://api.example.com/data. Why does the browser block the 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, or text/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.

Quiz
A fetch call uses the PUT method with a Content-Type of application/json. What happens?

Every Access-Control Header Explained

Request Headers (sent by the browser)

HeaderPurposeExample
OriginThe origin making the requesthttps://myapp.com
Access-Control-Request-MethodMethod the actual request will use (preflight only)PUT
Access-Control-Request-HeadersCustom headers the actual request will send (preflight only)Authorization, Content-Type

Response Headers (sent by the server)

HeaderPurposeExample
Access-Control-Allow-OriginWhich origin(s) can read the responsehttps://myapp.com or *
Access-Control-Allow-MethodsAllowed methods beyond simple onesGET, PUT, DELETE
Access-Control-Allow-HeadersAllowed custom headersAuthorization, X-Request-ID
Access-Control-Expose-HeadersResponse headers JS can readX-Total-Count, X-Request-ID
Access-Control-Max-AgeHow long to cache preflight results (seconds)86400
Access-Control-Allow-CredentialsWhether cookies/auth are allowedtrue

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.

Quiz
Your API returns a custom X-RateLimit-Remaining header. Your frontend JavaScript reads it as null despite seeing it in DevTools. What is wrong?

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
Common Trap

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 with Allow-Origin: https://app.example.com might be served to admin.example.com, breaking CORS
  • 204 for OPTIONS — no body needed for preflight responses
  • Max-Age: 86400 — reduces preflight overhead for repeated requests
What developers doWhat 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
Quiz
Why is the Vary: Origin header important in CORS responses?

Challenge: Debug This CORS Configuration

Challenge: Find the security and functionality bugs

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()
})
Key Rules
  1. 1CORS is enforced by the browser, not the server — the server cooperates by sending headers, the browser decides whether JS can read the response
  2. 2Never reflect the Origin header without validation — use an explicit Set of allowed origins
  3. 3Always set Vary: Origin when dynamically choosing the allowed origin — cache mismatches break CORS silently
  4. 4Credentials require a specific origin (not wildcard) and Access-Control-Allow-Credentials: true on both sides
  5. 5Set Access-Control-Max-Age to cache preflight responses — every uncached preflight adds a full round trip of latency
  6. 6Preflight exists to protect legacy servers from unexpected cross-origin side effects, not to authenticate requests