CORS Explained: From Beginner to Pro — Browser Security, Preflights, and Headers
A comprehensive developer's guide to Cross-Origin Resource Sharing (CORS) — covering the Same-Origin Policy, preflight OPTIONS requests, Access-Control headers, credentials sharing, security pitfalls, and interactive request-response flow tracing.
Every web developer has encountered it: you build a frontend application on localhost, attempt to fetch data from an API hosted on a different domain, and watch in frustration as your request fails with a red console error: "Blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource." This error is not a bug in your code, nor is it a server crash. It is a critical security feature of the modern web browser acting exactly as designed to protect users from malicious attacks. The mechanism behind this protection is **Cross-Origin Resource Sharing (CORS)**.
CORS is often treated by developers as a mysterious config option to be solved by blindly copying-and-pasting wildcards into server headers. Doing this bypasses crucial browser security boundaries, leaving your user data vulnerable to cross-site request forgery and data extraction. This guide will demystify CORS from first principles. We will examine the Same-Origin Policy that necessitates it, trace the exact flow of simple and preflight (OPTIONS) requests, analyze the headers exchanged, and explore how to configure CORS securely. By the end, you will implement a CORS middleware from scratch and visualize the HTTP flow using our interactive simulator.
1. The Foundation: Same-Origin Policy
1.1 What the Same-Origin Policy Is
To understand CORS, we must first understand the **Same-Origin Policy (SOP)**. Introduced by Netscape Navigator 2.0 in 1995, the SOP is a fundamental security model implemented by all modern web browsers. It restricts document or script loaded from one origin from reading or writing properties of a document loaded from another origin. In simple terms: code running on evil-hacker.com should not be allowed to make API calls to your-bank.com and read your account details. Without the SOP, the web would be completely insecure, as any website you visit could access your session cookies and read data from every other site you have open.
It is critical to note that the Same-Origin Policy **prevents reading, not always sending**. By default, a browser will allow a script on evil-hacker.com to submit a form post to your-bank.com. This is a write operation, and it succeeds on the server. However, the browser prevents the script from reading the server's response. This asymmetric protection is why CSRF (Cross-Site Request Forgery) attacks are possible: the attacker can send actions but not read results. The SOP protects data confidentiality but not complete data integrity, requiring developers to implement CSRF tokens alongside relying on the SOP.
1.2 Why Browser Isolation Is Essential
Web browsers store sensitive state information locally: session cookies, local storage variables, cache assets, and active authentication sessions. When a browser loads a webpage, it runs scripts inside an isolated context. The Same-Origin Policy is the sandbox boundary that prevents scripts running in one context from reading data from another. If you have your bank portal open in one tab and a random blog open in another, the SOP ensures the blog's scripts cannot execute code to read the bank page's DOM or cookies. Browser isolation is the single most important defense line of the modern web user.
Common Misconception — CORS Blocks Requests at the Server: A common misconception is that when a CORS error occurs, the server rejected the request. In reality, the server almost always processes the request and sends a response. The browser intercepts the response, notices the lack of proper CORS headers, blocks the response data from being read by the frontend script, and throws a console error. The request *was* executed by the server; you simply aren't allowed to read the result.
2. What Is an Origin? Defining Scheme, Host, and Port
2.1 The Mathematical Definition of Origin
In web security, an origin is defined mathematically as the combination of three components: the **protocol/scheme**, the **host/domain**, and the **port**. Two URLs share the same origin if and only if all three components match exactly. We can express this check as:
If any one of these three elements differs, the browser treats them as completely separate origins, and the Same-Origin Policy immediately applies. For example, http://example.com and https://example.com are different origins because their schemes differ (HTTP vs HTTPS). Similarly, http://example.com:80 and http://example.com:8080 are different due to the port difference.
2.2 Origin Match Matrix
To help visualize origin comparisons, here is a matrix evaluating whether various target URLs match the origin of a source page loaded from http://example.com/index.html (default port 80):
| Target URL | Same Origin? | Reason for Difference |
|---|---|---|
| http://example.com/about.html | Yes | Only path differs; scheme, host, and port match. |
| https://example.com/index.html | No | Scheme mismatch (HTTP vs HTTPS). |
| http://api.example.com/index.html | No | Host mismatch (subdomain counts as different host). |
| http://example.com:8080/index.html | No | Port mismatch (80 vs 8080). |
Pitfall — Subdomain Mismatch: Many developers are surprised to find that subdomains (e.g. api.example.com vs app.example.com) are treated as completely different origins by the browser. If your frontend app needs to fetch data from your API hosted on a subdomain, you must configure CORS on the API server. They are not considered "same-origin" simply because they share the same parent domain.
3. Introducing CORS: Releasing the Same-Origin Policy Safely
3.1 Why We Need Cross-Origin Resources
While the Same-Origin Policy is essential for security, it is highly restrictive. In the modern web, applications are distributed across multiple servers: a frontend single-page app (SPA) might be hosted on a CDN (e.g. frontend.com), while its API services run on a cloud provider (e.g. api.com), and user images are loaded from an S3 bucket (e.g. images.s3.amazonaws.com). If the browser blocked all cross-origin requests, modern distributed web architectures would be impossible. We need a way to selectively release the SOP, allowing trusted cross-origin requests while blocking unauthorized ones. That is what CORS does.
CORS (Cross-Origin Resource Sharing) is a W3C standard that allows a server to declare who is allowed to access its resources. It works by having the browser and the server exchange specific HTTP headers. The browser sends the request's origin to the server, and the server responds indicating whether that origin is permitted. If permitted, the browser allows the frontend script to read the response; if not, it blocks it and throws an error. CORS is a cooperative security handshake between the browser and the server.
4. Simple Requests vs Preflight Requests
4.1 Simple Requests
To minimize performance overhead, the CORS specification divides requests into two categories: **Simple Requests** and **Preflight Requests**. A request is considered a Simple Request if it meets all of the following conditions: (1) it uses a method of GET, POST, or HEAD, (2) it only uses standard headers (like Accept, Accept-Language, Content-Language), and (3) its Content-Type is either application/x-www-form-urlencoded, multipart/form-data, or text/plain. For these requests, the browser sends the HTTP request directly to the server, attaching an Origin header. The server responds, and if the response contains a matching Access-Control-Allow-Origin, the browser allows the read.
4.2 Preflight Requests (OPTIONS)
If a request does *not* meet the simple request criteria (for example, if it uses a PUT or DELETE method, or if it sets a Content-Type of application/json, or includes custom headers like Authorization), the browser will not send the request directly. Instead, it first sends a **Preflight Request** using the **OPTIONS** method. The preflight acts as a safety check: it asks the server for permission before sending the actual request. If the server approves the preflight by responding with the correct CORS headers, the browser immediately sends the actual request; if the preflight fails, the browser blocks the actual request from ever being sent, protecting the server from unauthorized state modifications.
Mermaid Diagram: The CORS preflight sequence. An OPTIONS request checks permissions before the actual POST is sent.
Pitfall — Preflight Blocking Server Updates: Many developers are confused when their POST request works, but their PUT request fails with a CORS error before reaching the server's controller code. This is because the browser blocked the actual PUT during the preflight stage because the server did not handle the OPTIONS preflight request correctly. Your backend must be configured to respond to OPTIONS requests with a 200 OK status and the appropriate CORS headers, rather than routing it through your normal authentication or routing pipelines.
5. Anatomy of a Preflight Request
5.1 Request Headers Traced
When a browser triggers a preflight, it constructs an HTTP OPTIONS request and attaches three critical headers that declare the intention of the actual request: (1) **Origin**: the origin of the calling page (e.g. http://localhost:3000). (2) **Access-Control-Request-Method**: the HTTP method of the actual request (e.g. PUT). (3) **Access-Control-Request-Headers**: a comma-separated list of any custom headers the actual request will include (e.g. authorization, x-custom-header). Together, these headers ask the server: "Is origin X allowed to send method Y with headers Z?"
6. Access-Control Response Headers
6.1 Access-Control-Allow-Origin
The server's response to the preflight (or simple request) must contain specific headers for the browser to allow access. The most critical header is **Access-Control-Allow-Origin**. This header specifies which origin(s) are allowed to read the response. The server can respond with: (1) a wildcard * (allowing any origin, but with security constraints discussed in Section 7), or (2) the exact origin reflecting the request's Origin header (e.g. Access-Control-Allow-Origin: http://localhost:3000).
6.2 Other Critical Response Headers
Alongside the origin, the server must authorize the methods and headers requested: (1) **Access-Control-Allow-Methods**: a list of allowed HTTP methods (e.g. GET, POST, PUT, DELETE, OPTIONS). (2) **Access-Control-Allow-Headers**: a list of allowed custom headers (e.g. Authorization, Content-Type). (3) **Access-Control-Max-Age**: the number of seconds the browser is allowed to cache the preflight response (e.g. 86400 for 24 hours). Caching the preflight avoids sending duplicate OPTIONS requests for subsequent API calls, reducing latency dramatically.
Pitfall — Wildcard Origin with Credentials: Setting Access-Control-Allow-Origin: * is a security risk, and the browser enforces a strict rule: if the actual request requires credentials (cookies, HTTP basic authentication, client certificates), you **cannot** use the wildcard *. The browser will block the request if credentials are sent and the origin is wildcarded. The server must explicitly reflect the requesting origin back in the header instead.
7. Advanced: Credentials and Cookies in CORS
7.1 Access-Control-Allow-Credentials
By default, cross-origin requests do not send credentials (cookies or authorization headers). To enable credential sharing, the frontend must set the withCredentials flag to true on the XMLHttpRequest or set credentials: 'include' in the fetch options. On the server side, the response must include **Access-Control-Allow-Credentials: true** and **must explicitly define a single origin** in Access-Control-Allow-Origin. Setting both Allow-Credentials: true and Allow-Origin: * is invalid and rejected by the browser, protecting users from having their cookies stolen by random websites.
7.2 SameSite Cookie Attribute
Modern browsers also enforce the **SameSite** cookie attribute, which controls whether cookies are sent on cross-origin requests. If a cookie is marked SameSite=Strict, it is never sent on cross-origin requests. If SameSite=Lax (the default in modern Chrome), it is sent only on cross-origin GET navigations (like clicking a link), but not on AJAX requests. To allow cookies on cross-origin AJAX fetches, the cookie must be marked **SameSite=None; Secure**, requiring HTTPS. This is a critical layer of defense that complements CORS.
8. Advanced: CORS Security Vulnerabilities
8.1 Origin Reflection Attack
Because the wildcard * cannot be used with credentials, many backend developers implement a workaround: read the incoming Origin header and reflect it back in Access-Control-Allow-Origin dynamically. This satisfies the credential constraint, but if done without validation, it is a severe security vulnerability. It means *any* website in the world can make credentialed calls to your API. An attacker can host a malicious script that fetches sensitive user data using the user's active session cookies. Always validate the incoming origin against an explicit whitelist of trusted domains before reflecting it.
8.2 Exposing Headers
By default, the browser only exposes a small set of standard response headers to the calling script (like Cache-Control, Content-Language, Content-Type). If your API returns custom headers (such as a pagination count X-Total-Count or an updated JWT in X-New-Token), the frontend script cannot read them. To allow the frontend to access these custom headers, the server must include the **Access-Control-Expose-Headers** header specifying the allowed names, otherwise they remain hidden inside the browser sandbox.
9. Complete Express/NodeJS CORS Middleware Implementation
9.1 Express CORS Middleware from Scratch
The following code implements a fully functional CORS middleware in Node.js/Express from scratch, demonstrating how to handle origin whitelisting, custom headers, credentials, and preflight OPTIONS responses manually:
10. Interactive: CORS Request Flow Simulator
Select the request configuration, then click Run. Watch how the browser sends an OPTIONS preflight request first for non-simple requests, inspects the headers returned by the server, and then decides whether to block or send the actual request:
11. The Performance Cost of Preflight Requests
The chart below compares the total number of HTTP requests sent by a browser for 100 API calls with different caching configurations. Caching the preflight using Access-Control-Max-Age reduces total requests by 50%:
12. Frequently Asked Questions
Q1: Does CORS protect my API server from unauthorized requests?
No. CORS is a browser-side security mechanism. It prevents scripts running in a browser from reading your API responses. It does not block requests made from non-browser clients (like Postman, cURL, or backend servers). An attacker can easily scrape your API using a node script, bypassing CORS completely. Protect your API using authentication tokens (JWT, API keys) and rate limiting, not CORS.
Q2: Why does the wildcard Access-Control-Allow-Origin: * fail when credentials: 'include' is set?
This is a deliberate security restriction. If credentials are allowed, the browser sends user-specific session cookies with the request. If the server responded with Access-Control-Allow-Origin: *, it would mean *any* malicious website in the world could make credentialed calls to your API and read the user's data. To allow credentials, the server must explicitly declare the single origin it permits.
Q3: What is the purpose of the Access-Control-Max-Age header?
The Access-Control-Max-Age header tells the browser how long (in seconds) it is allowed to cache the preflight OPTIONS response. Caching the preflight avoids sending duplicate OPTIONS checks for every subsequent AJAX call, reducing latency and network traffic. Browsers enforce a maximum cap on this value (typically 10 minutes in Chrome, 24 hours in Firefox).
Q4: Why can't my frontend script read custom response headers like pagination counts?
By default, the browser sandboxes response headers, only exposing standard fields (like Content-Type). To expose custom headers (such as a pagination index X-Total-Count or an updated token) to your Javascript code, the server must explicitly authorize them by sending the Access-Control-Expose-Headers header.
Q5: How does the SameSite cookie attribute interact with CORS?
Even if CORS is configured correctly to allow credentials, the browser will not send cookies on cross-origin AJAX fetches if the cookie's SameSite attribute is set to Strict or Lax. To allow cookies on cross-origin requests, the cookie must be marked SameSite=None; Secure, which requires the connection to use HTTPS.
Q6: What is a simple request in CORS?
A simple request is one that uses a safe HTTP method (GET, POST, HEAD), safe headers, and a simple Content-Type (like text/plain). Simple requests bypass the preflight OPTIONS check, sending the request directly to the server to reduce latency. Non-simple requests (like sending JSON data) require a preflight.
Q7: What is an origin reflection attack?
An origin reflection attack occurs when a backend developer reflects the incoming Origin header in the Access-Control-Allow-Origin header dynamically without validation. This satisfies the credentialed origin check, but permits *any* malicious website to make credentialed requests to your API. Always validate origins against an explicit whitelist.
Q8: How do I test CORS configurations?
Verify: (1) simple requests send the correct Origin header and verify the response headers, (2) preflight OPTIONS requests return a 200 OK status with the correct Access-Control headers, (3) credentialed requests verify that Access-Control-Allow-Origin matches whitelisted origins and is not a wildcard, and (4) verify that invalid origins do not receive Access-Control headers.