About Posts Series Contact
Security 14 min read

CORS: Beyond 'Just Add the Header'

Most CORS fixes are copied and pasted. This post builds the Same-Origin Policy from first principles, traces the full preflight, and shows the wildcard trap, Vary header problem, and CORS misconfigurations that ship to production.

Honey Sharma

DevForum’s new frontend launches on app.devforum.com. The backend API stays on api.devforum.com. The developer opens the browser, loads the dashboard, and watches the network tab. Every API request returns 200. The JavaScript receives nothing. The console says:

Access to fetch at 'https://api.devforum.com/v1/posts' from origin 
'https://app.devforum.com' has been blocked by CORS policy: No 
'Access-Control-Allow-Origin' header is present on the requested resource.

Most developers respond by adding Access-Control-Allow-Origin: * to the API and moving on. This post is about understanding what you just did — and when that answer is the wrong one.


The Same-Origin Policy, From First Principles

Before CORS can make sense, the restriction it relaxes needs to be clear.

What is an “origin”?

An origin is a triple: scheme + host + port. All three must match for two URLs to share an origin. The path, query string, and fragment are irrelevant.

Base URL: https://devforum.com

https://devforum.com/posts           → Same origin   (path doesn't count)
http://devforum.com                  → Different     (scheme: https ≠ http)
https://app.devforum.com             → Different     (host: subdomain ≠ apex)
https://api.devforum.com             → Different     (host: different subdomain)
https://devforum.com:8080            → Different     (port: 443 ≠ 8080)
https://devforum.com:443             → Same origin   (443 is the default HTTPS port)

What SOP restricts — and what it doesn’t

The Same-Origin Policy restricts JavaScript reading cross-origin responses. It does not restrict much else:

ActionCross-origin allowed?
Navigate (<a href>, window.location)Yes
Form submission (<form action>)Yes — response blocked, not request
Embed resources (<img src>, <script src>, <link href>)Yes
fetch() or XMLHttpRequestsending the requestYes
fetch() or XMLHttpRequestreading the responseNo — this is what SOP blocks
SOP Asymmetry — The Request Goes Through, the Read Does Not
fetch('https://yourbank.com/balance') JavaScript running on evil.comGET /balance Cookie: session=abc123 (auto-attached)request goes through200 OK — { balance: '$12,450' } SOP blocks: evil.com cannot read response body read is blocked evil.com (malicious page) Victim's Browser yourbank.com
SOP does not stop the request. The server receives it, processes it, and responds. SOP only prevents the originating page from reading the response. This is why CSRF is a separate problem — the state change happens before the read is blocked.

The asymmetry is critical. The request goes through. The browser sends the POST to api.devforum.com. The server receives it and processes it. SOP blocks the JavaScript from reading the response. The state change already happened.

This asymmetry is exactly why CSRF is a separate problem, not a CORS problem — and why we cover it in the next post.

The threat SOP prevents: without it, any malicious page could silently fetch('https://yourbank.com/balance') using your session cookie and read your account balance. Any site could read the contents of your authenticated sessions on any other site. SOP prevents cross-origin response reads by default.

What CORS Actually Is

CORS (Cross-Origin Resource Sharing) is not a security restriction. It’s a relaxation of SOP.

The browser’s default is to block cross-origin response reads. CORS is the mechanism by which a server tells the browser: “I permit this specific origin to read my responses.”

The right question when adding CORS is not “how do I make this work?” — it’s “which origins should legitimately be able to read this API’s responses?”

Simple vs Preflighted Requests

The browser splits cross-origin requests into two categories based on their risk profile.

Simple requests are sent directly. The browser attaches the Origin header and checks the response’s Access-Control-Allow-Origin header. If it matches, the response is given to JavaScript. If not, the response is blocked.

A request is simple if it meets all three conditions:

  • Method is GET, HEAD, or POST
  • Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No custom headers beyond the CORS-safelisted headers

Preflighted requests trigger a preliminary OPTIONS request before the actual request is sent. The preflight asks the server: “will you allow this method, from this origin, with these headers?” Only if the server answers yes does the browser send the actual request.

What triggers a preflight:

  • Any method other than GET, HEAD, or POST
  • Content-Type: application/json (or any non-simple type)
  • Any custom header (Authorization, X-Request-ID, X-API-Key)
Is This Request Simple or Preflighted?
yes no yes no — e.g. application/json yes no — e.g. Authorization Cross-origin request Method is GET, HEAD, or POST? Content-Type is url-encoded, form-data, or text/plain? All headers are CORS-safelisted? Accept, Content-Language, etc. Simple request — no preflight Preflighted — OPTIONS sent first
All three conditions must hold for a simple request. A single custom header or application/json Content-Type triggers a preflight. Most SPA API calls are preflighted.

DevForum’s API call: fetch('https://api.devforum.com/v1/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }) triggers a preflight because of the application/json content type. (credentials: 'include' means the browser will send cookies and Authorization headers with the request — a credentialed request. Credentialed requests are covered in detail in the next section.)

The Preflight Flow in Full Detail

CORS Preflight + Credentialed Request — Complete Flow
OPTIONS /v1/posts Origin: app.devforum.com AC-Request-Method: POST AC-Request-Headers: Content-Type① preflight204 No Content AC-Allow-Origin: app.devforum.com AC-Allow-Methods: GET,POST AC-Max-Age: 86400POST /v1/posts Origin: app.devforum.com Content-Type: application/json Cookie: session=abc② actual request201 Created AC-Allow-Origin: app.devforum.com AC-Allow-Credentials: true app.devforum.com api.devforum.com
The preflight asks permission before any state-changing request is sent. The browser sends cookies on the actual request only after the server confirms it permits them.
Preflight Request / Response Headers — Annotated
OPTIONS Request (browser-generated)
Origin app.devforum.com
Access-Control-Request-Method POST
Access-Control-Request-Headers Content-Type
Server Preflight Response
Access-Control-Allow-Origin app.devforum.com
Access-Control-Allow-Methods GET, POST, PATCH
Access-Control-Allow-Headers Content-Type, Authorization
Access-Control-Max-Age 86400
Vary Origin
The browser generates the OPTIONS request automatically. Three request headers describe the intended actual request; the server's response determines whether the browser sends it.

Credentialed Requests and the Wildcard Trap

By default, cross-origin fetch() calls do not include cookies or Authorization headers. To send credentials, you must opt in:

fetch('https://api.devforum.com/v1/me', {
  credentials: 'include'  // include cookies and auth headers
});

When credentials: 'include' is set, the browser enforces two additional rules on the response:

  1. Access-Control-Allow-Origin must be a specific origin — not *
  2. Access-Control-Allow-Credentials: true must be present

If either is missing, the browser blocks the response from reaching JavaScript — even though the server sent a 200.

Vary: Origin and CDN Caching

CDNs (Content Delivery Networks) cache HTTP responses at edge servers close to your users — they store and replay responses rather than always hitting your origin server. This is what makes them fast, and it’s what makes the next bug subtle.

When Access-Control-Allow-Origin is set dynamically — different values for different requesting origins — the response must include Vary: Origin.

Without it, a CDN may cache the response for one origin and serve it to requests from a different origin. The cached response includes ACAO headers that permit app.devforum.com. A request from otherdomain.com gets the same cached response — with headers permitting the wrong origin.

// Express — correct CORS + Vary header pattern
const ALLOWED_ORIGINS = new Set([
  'https://app.devforum.com',
  'https://admin.devforum.com',
]);

app.use((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');
  }
  // Always set Vary when ACAO is dynamic
  res.setHeader('Vary', 'Origin');
  next();
});

What CORS Does Not Protect Against

Two things developers routinely assume CORS handles that it does not:

CSRF. A form on evil.com can POST to api.devforum.com. The browser sends the request — including any cookies attached to api.devforum.com. SOP blocks evil.com’s JavaScript from reading the response. The state change (delete account, transfer funds, post comment) has already happened. CORS does not prevent CSRF. That requires CSRF tokens or SameSite cookies — the subject of the next post.

Non-browser clients. curl, Postman, Python’s requests, any server-to-server HTTP call — these ignore CORS headers entirely. CORS is a browser contract. It tells browsers which origins may read responses. It tells nothing to any other HTTP client. If your API contains sensitive data, authentication and authorisation on every endpoint are still required independently of CORS configuration.

Common Misconfigurations

Reflecting the Origin header verbatim. The worst pattern — and surprisingly common:

// Vulnerable — allows any origin to read the response
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);

Any site in the world can now read responses from your API as your authenticated users.

Overly broad regex. An attempt to validate the origin that introduces a bypass:

// Vulnerable — devforumevil.com passes this check
if (/devforum/.test(req.headers.origin)) {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}

// Fixed — exact set membership check
const ALLOWED = new Set(['https://app.devforum.com', 'https://devforum.com']);
if (ALLOWED.has(req.headers.origin)) {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}

Missing Vary: Origin. Covered above — dynamic ACAO headers without Vary creates CDN caching bugs. Always add it.

Wildcard with credentials. Access-Control-Allow-Origin: * combined with credentials: 'include' — browser blocks the response per spec. This is a configuration error, not a browser bug.

All four misconfigurations appear in production codebases. The regex bypass is the least obvious — it looks like a validation but isn't.
Pattern What it does The risk The fix
Reflect origin verbatimres.set('ACAO', req.headers.origin) with no checkAny origin reads the APIValidate against an explicit Set of allowed origins
Broad regex/devforum/.test(origin)devforumevil.com passesExact string match against a Set
Wildcard + credentialsACAO: * + Allow-Credentials: trueBrowser blocks response — silent auth breakageUse specific origin when credentials: 'include'
Missing Vary: OriginDynamic ACAO without Vary headerCDN serves stale CORS headers to wrong originAlways add Vary: Origin on dynamic ACAO responses

Request Type Reference

Credentialed is not a third category — any simple or preflighted request can also be credentialed. The credentials flag changes what the server must return, not whether a preflight fires.
Type What triggers it Preflight? Credentials? Required server headers
SimpleGET/HEAD/POST + safe content-type + no custom headersNoOnly with credentials: includeAccess-Control-Allow-Origin
Preflightedapplication/json, custom headers, or non-simple methodYes — OPTIONS firstOnly with credentials: includeAccess-Control-Allow-Origin + Methods + Headers
Credentialedcredentials: 'include' on any requestIf non-simpleYes — cookies and auth headers sentSpecific ACAO (not *) + Allow-Credentials: true
CORS Deployment Checklist
Validate Origin against an explicit allowlist, not a regex

Maintain a Set of permitted origins and check with .has(). Regex patterns on origin values introduce substring bypass vulnerabilities. Exact string matching does not.

Add Vary: Origin whenever Access-Control-Allow-Origin is dynamic

If your ACAO value changes per request (different values for different origins), every CORS response must include Vary: Origin. Without it, CDN or proxy caching will serve the wrong ACAO header to the wrong origin.

Never use Access-Control-Allow-Origin: * for authenticated APIs

Wildcard is appropriate for public read APIs: fonts, public datasets, open image CDNs. Any endpoint that reads session state, user data, or authenticated content needs a specific allowed origin.

CORS does not replace CSRF protection

SOP blocks the response read, not the request. A cross-site POST to a state-changing endpoint still executes — it just can’t read the response. State-changing endpoints need CSRF tokens or SameSite cookie attributes regardless of CORS configuration.

CORS headers have no effect on non-browser clients

curl, Postman, and server-to-server requests ignore CORS entirely. Authentication and authorisation on every endpoint are independently required. CORS is a browser contract, not an API access control mechanism.

Honey Sharma

Software engineer focused on web engineering, TypeScript, and distributed systems.