~/blog/-blog-cors-explained-
blog · HTTP

CORS Explained: Why It Exists, How Preflight Works, and How to Fix It

What CORS actually is, why the browser enforces it (but curl doesn't), how preflight requests work, and the correct server-side headers to set without opening security holes.

last updated · June 14, 2026by @vultio

The one sentence that makes CORS make sense

CORS (Cross-Origin Resource Sharing) is a browser security feature that blocks web pages from making requests to a different domain than the one that served them — unless the target server explicitly says it allows it. That is it. Everything else is implementation detail.

The key insight: CORS is enforced by the browser, not the server and not the network. When you call an API with curl, Postman, or server-side code, CORS does not apply. When JavaScript running in a browser makes a fetch() call to a different origin, the browser checks the CORS headers on the response and decides whether to let the JavaScript code see the response. The request was already made and the server already responded — the browser is the last gatekeeper.

What "origin" means

An origin is the combination of scheme (protocol), host, and port. Two URLs have the same origin only if all three match exactly.

# Same origin as https://app.example.com
https://app.example.com/dashboard     ✓ same origin
https://app.example.com/api/data      ✓ same origin

# Different origin — CORS applies
http://app.example.com                ✗ different scheme (http vs https)
https://api.example.com               ✗ different subdomain
https://example.com                   ✗ different host
https://app.example.com:8080          ✗ different port

This is why frontend apps on https://app.example.com need CORS configured to call https://api.example.com — even though they share the same root domain, the subdomain difference makes them different origins.

Why CORS exists: the attack it prevents

Without the same-origin policy (which CORS builds on), any malicious website could make authenticated requests to any other site using your browser session. Imagine you are logged into your bank at bank.com. You visit evil.com. JavaScript onevil.com could silently call bank.com/transfer?to=attacker&amount=5000using your browser's existing session cookie — and read the response to confirm it worked.

The same-origin policy prevents evil.com's JavaScript from reading the response from bank.com. CORS is the mechanism that allows bank.com to selectively loosen this restriction for specific trusted origins — for example, allowingapp.bank.com to read responses from api.bank.com.

Simple vs preflighted requests

Not all cross-origin requests trigger a preflight. "Simple" requests — GET, HEAD, or POST with only basic headers and standard content types — are sent directly. The browser checks the CORS headers on the response after the fact. This is a historical artifact: the browser could not prevent these requests from being sent (they match the behavior of HTML forms), so CORS only controls whether JavaScript can read the response.

Requests that do not fit the simple category trigger a preflight: the browser automatically sends an HTTP OPTIONS request to the same URL before the actual request, asking the server "do you allow this type of request from this origin?" The server must respond with the appropriate headers, or the browser will block the actual request before it is even sent.

What triggers a preflight

# Simple request — no preflight
fetch('https://api.example.com/data')  // GET with no custom headers

# Preflighted — custom header triggers OPTIONS first
fetch('https://api.example.com/data', {
  headers: { 'Authorization': 'Bearer token' }  // non-standard header
})

# Preflighted — non-simple content type
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // triggers preflight
  body: JSON.stringify({ name: 'Alice' })
})

# Preflighted OPTIONS request the browser sends automatically:
OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The CORS headers your server must send

# Response to preflight OPTIONS request
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400   # cache preflight result for 24 hours

# Response to actual request — must also include Allow-Origin
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

# To allow credentials (cookies, Authorization headers)
Access-Control-Allow-Origin: https://app.example.com  # MUST be specific, not *
Access-Control-Allow-Credentials: true

The most important rule: if you use Access-Control-Allow-Credentials: true, you cannot use Access-Control-Allow-Origin: *. The browser will reject it. You must specify the exact origin. If you need to allow multiple origins, check the request'sOrigin header against an allowlist on the server and reflect it back dynamically:

// Node.js — dynamic origin allowlist
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');  // tell caches the response varies by 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');
    return res.sendStatus(204);
  }
  next();
});

Diagnosing CORS errors

CORS errors appear in the browser's console, not in the Network tab as a failed request. The request succeeds at the network level (you can see it in the Network tab with a 200 or other status), but the browser blocks the JavaScript from accessing the response body. This is why "it works in Postman but fails in the browser" is the canonical CORS symptom.

To diagnose: open DevTools, go to the Network tab, find the failing request. If there is a corresponding OPTIONS request before it, look at the OPTIONS response headers. If there is no OPTIONS request, the failure is on the simple request's response. In both cases, check whether Access-Control-Allow-Origin is present, whether it matches the requesting origin exactly, and whether the method and headers used are in the allowed list.

Do not use browser extensions that disable CORS. They work by injecting headers or proxying requests, which hides the real issue and leaves you with a configuration that works in development but breaks in production for every other user. Fix CORS at the server, not the browser.

The wildcard trap

Setting Access-Control-Allow-Origin: * allows any origin to read responses, which is appropriate for truly public APIs (CDN assets, public data endpoints) but wrong for any API that handles user data or requires authentication. A wildcard origin combined with authentication means any website in the world can call your API using your users' sessions. Restrict the allowed origins to the specific domains that legitimately need access.