How to Handle Cross-Origin Requests (CORS) with Fetch in JavaScript
Introduction
The moment you try to fetch data from a different domain, you will encounter one of the most confusing error messages in web development:
Access to fetch at 'https://api.example.com/data' from origin 'https://mysite.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
This error does not mean your code is broken. It means the browser is protecting the user by enforcing the Same-Origin Policy, a fundamental security mechanism that prevents websites from reading data from other websites without explicit permission. CORS (Cross-Origin Resource Sharing) is the system that allows servers to grant that permission selectively.
Understanding CORS is not optional for any JavaScript developer who works with APIs. Every time your frontend application calls a backend on a different domain (or even a different port during development), CORS is involved. Misunderstanding how it works leads to hours of debugging, incorrect workarounds, and security vulnerabilities.
In this guide, you will learn what the Same-Origin Policy is and why it exists, how CORS works with its two types of requests (simple and preflight), what each CORS header does and who sets it, and how credentials like cookies work in cross-origin requests.
What Is the Same-Origin Policy?
The Same-Origin Policy is a browser security mechanism that restricts how JavaScript on one page can interact with resources from another origin. It is one of the oldest and most important security features of the web.
What Is an "Origin"?
An origin is defined by three parts of a URL:
- Protocol (scheme):
httporhttps - Host (domain):
example.com,api.example.com,localhost - Port:
80,443,3000,8080
Two URLs have the same origin only if all three parts match exactly:
| URL A | URL B | Same Origin? | Why? |
|---|---|---|---|
https://site.com/page1 | https://site.com/page2 | Yes | Same protocol, host, port |
https://site.com | http://site.com | No | Different protocol |
https://site.com | https://api.site.com | No | Different host (subdomain) |
https://site.com | https://site.com:8080 | No | Different port |
http://site.com:80 | http://site.com | Yes | Port 80 is default for HTTP |
https://site.com:443 | https://site.com | Yes | Port 443 is default for HTTPS |
Why Does the Same-Origin Policy Exist?
Imagine you are logged into your bank at https://mybank.com. Without the Same-Origin Policy, any website you visit could do this:
// A malicious site at https://evil-site.com could run:
const response = await fetch('https://mybank.com/api/accounts');
const accounts = await response.json();
// Now evil-site.com has your bank account data!
// The browser sent your mybank.com cookies automatically.
The Same-Origin Policy prevents this. JavaScript running on https://evil-site.com cannot read the response from https://mybank.com, even though the browser might send the request. The response is blocked before JavaScript can access it.
The Same-Origin Policy blocks reading the response, not necessarily sending the request. The request might still reach the server. This distinction is important for understanding CORS and why servers need to be careful about side effects from cross-origin requests.
What the Same-Origin Policy Restricts
The policy primarily affects JavaScript's ability to read responses:
// Same-origin: works normally
await fetch('/api/data'); // Relative URL = same origin
// Cross-origin: blocked by default
await fetch('https://different-domain.com/api/data');
// Request may be sent, but JavaScript cannot read the response
Not everything is restricted. Certain types of cross-origin requests have always been allowed by browsers, because they were part of the web before the Same-Origin Policy existed:
<img src="https://other-site.com/image.jpg">loads cross-origin images<script src="https://cdn.example.com/library.js">loads cross-origin scripts<link rel="stylesheet" href="https://cdn.example.com/style.css">loads cross-origin stylesheets<form action="https://other-site.com/submit" method="POST">submits cross-origin forms
These elements can load and execute cross-origin resources, but JavaScript cannot read their contents programmatically (with some exceptions). CORS extends this model by giving servers a way to explicitly allow JavaScript to read cross-origin responses from fetch() and XMLHttpRequest.
How CORS Works: The Big Picture
CORS is a server-side permission system. The server tells the browser which origins are allowed to read its responses. The browser enforces this.
Here is the flow:
- Your JavaScript calls
fetch('https://api.example.com/data')fromhttps://mysite.com - The browser adds an
Origin: https://mysite.comheader to the request - The server receives the request and checks the
Originheader - If the server allows this origin, it includes
Access-Control-Allow-Origin: https://mysite.comin the response - The browser checks the response headers. If the CORS headers are present and valid, JavaScript can read the response. If not, the browser blocks it.
Browser (mysite.com) Server (api.example.com)
| |
| GET /data |
| Origin: https://mysite.com |
| ─────────────────────────────────────► |
| |
| 200 OK |
| Access-Control-Allow-Origin: https://mysite.com
| ◄───────────────────────────────────── |
| |
✓ Browser allows JavaScript to read response
If the server does not include the Access-Control-Allow-Origin header (or includes a different origin), the browser blocks JavaScript from reading the response:
Browser (mysite.com) Server (api.example.com)
| |
| GET /data |
| Origin: https://mysite.com |
| ─────────────────────────────────────► |
| |
| 200 OK |
| (no CORS headers) |
| ◄───────────────────────────────────── |
| |
✗ Browser BLOCKS JavaScript from reading response
✗ CORS error in the console
CORS errors happen in the browser, not on the server. The server receives the request and sends a normal response. The browser then checks the CORS headers and decides whether JavaScript is allowed to see the response. This is why you can often see the correct response in the Network tab of DevTools, but your JavaScript code gets an error.
This also means CORS is not a security measure for the server. It protects the user's browser from malicious websites. Server-side security (authentication, authorization, input validation) is still entirely the server's responsibility.
Simple vs. Preflight Requests
CORS divides cross-origin requests into two categories: simple requests and preflighted requests. The browser decides which type to use based on the characteristics of the request. You do not choose; the browser determines it automatically.
Simple Requests
A request is considered "simple" (technically, it satisfies the CORS-safelisted criteria) if all of these conditions are met:
Method is one of:
GETHEADPOST
Headers are limited to CORS-safelisted headers:
AcceptAccept-LanguageContent-LanguageContent-Type(with restrictions below)
Content-Type (if present) is one of:
application/x-www-form-urlencodedmultipart/form-datatext/plain
If all these conditions are met, the browser sends the request directly with an Origin header, and checks the response for CORS headers:
// This is a simple request
// Method: GET, no custom headers
const response = await fetch('https://api.example.com/data');
// This is also a simple request
// Method: POST with an allowed Content-Type
const response = await fetch('https://api.example.com/form', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'name=Alice&age=30'
});
Browser Server
| |
| GET /data |
| Origin: https://mysite.com |
| ──────────────────────────────────► |
| |
| 200 OK |
| Access-Control-Allow-Origin: * |
| { "data": "hello" } |
| ◄────────────────────────────────── |
| |
✓ Response allowed
Preflighted Requests
Any request that does not meet the simple request criteria triggers a preflight. The browser sends an automatic OPTIONS request before the actual request to ask the server for permission.
Common triggers for preflight:
// Trigger: Custom header
fetch('https://api.example.com/data', {
headers: {
'Authorization': 'Bearer token123' // Not a safelisted header
}
});
// Trigger: Content-Type is application/json
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // Not a safelisted Content-Type
},
body: JSON.stringify({ name: 'Alice' })
});
// Trigger: Non-simple method
fetch('https://api.example.com/data/1', {
method: 'PUT', // Not GET, HEAD, or POST
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'Updated' })
});
// Trigger: DELETE method
fetch('https://api.example.com/data/1', {
method: 'DELETE' // Not GET, HEAD, or POST
});
The preflight sequence has two steps:
Browser Server
| |
| ── Step 1: Preflight ── |
| |
| OPTIONS /data |
| Origin: https://mysite.com |
| Access-Control-Request-Method: PUT |
| Access-Control-Request-Headers: Content-Type, Authorization
| ──────────────────────────────────────► |
| |
| 204 No Content |
| Access-Control-Allow-Origin: https://mysite.com
| Access-Control-Allow-Methods: GET, POST, PUT, DELETE
| Access-Control-Allow-Headers: Content-Type, Authorization
| Access-Control-Max-Age: 86400 |
| ◄────────────────────────────────────── |
| |
| ── Step 2: Actual Request ── |
| |
| PUT /data |
| Origin: https://mysite.com |
| Content-Type: application/json |
| Authorization: Bearer token123 |
| { "name": "Updated" } |
| ──────────────────────────────────────► |
| |
| 200 OK |
| Access-Control-Allow-Origin: https://mysite.com
| { "result": "success" } |
| ◄────────────────────────────────────── |
| |
✓ Response allowed
Step 1 (Preflight): The browser sends an OPTIONS request with:
Origin: Which origin is making the requestAccess-Control-Request-Method: Which HTTP method the actual request will useAccess-Control-Request-Headers: Which custom headers the actual request will include
The server responds with what it allows. If the server's response indicates that the method and headers are permitted, the browser proceeds to Step 2.
Step 2 (Actual Request): The browser sends the real request. The server includes CORS headers in the response. The browser checks them and lets JavaScript read the response.
If the preflight fails (the server does not allow the method, headers, or origin), the browser never sends the actual request, and you see a CORS error in the console.
Why Does This Distinction Exist?
Simple requests mirror what was already possible before CORS existed. An HTML <form> can send a POST request with Content-Type: application/x-www-form-urlencoded to any origin. So allowing these cross-origin from JavaScript does not create new attack vectors.
Preflighted requests involve capabilities that <form> elements never had: custom headers like Authorization, methods like PUT and DELETE, and content types like application/json. The preflight check ensures the server explicitly opts in to receiving these types of requests from other origins.
Checking Preflight in DevTools
You can see preflight requests in the Network tab of your browser's DevTools. Look for OPTIONS requests that appear right before your actual request. They are often filtered out by default, so make sure the "All" filter is selected (not just "Fetch/XHR"):
Network Tab:
Method Status URL Type
OPTIONS 204 https://api.example.com/data preflight
PUT 200 https://api.example.com/data fetch
CORS Headers Explained
CORS involves several HTTP headers, some sent by the browser (request headers) and some sent by the server (response headers). Let us cover each one.
Request Headers (Sent by the Browser)
These headers are added automatically by the browser. You never set them manually in your JavaScript code.
Origin
Sent with every cross-origin request. Tells the server which origin the request comes from:
Origin: https://mysite.com
The browser adds this automatically. You cannot modify or remove it. If you try to set it manually in fetch(), the browser ignores your value and uses the real origin.
Access-Control-Request-Method
Sent only with preflight OPTIONS requests. Tells the server which HTTP method the actual request will use:
Access-Control-Request-Method: PUT
Access-Control-Request-Headers
Sent only with preflight OPTIONS requests. Tells the server which custom headers the actual request will include:
Access-Control-Request-Headers: Content-Type, Authorization, X-Custom-Header
Response Headers (Set by the Server)
These headers must be set by the server. If your CORS request is failing, the fix is almost always on the server side, not in your JavaScript code.
Access-Control-Allow-Origin
The most important CORS header. Specifies which origin(s) are allowed to read the response:
# Allow a specific origin
Access-Control-Allow-Origin: https://mysite.com
# Allow any origin (public API)
Access-Control-Allow-Origin: *
This header must be present on every cross-origin response (both preflight and actual).
Key rules:
- The value must be exactly one origin or
*(not a list of origins) - If you need to allow multiple specific origins, the server must check the
Originrequest header and respond dynamically *cannot be used when credentials (cookies) are involved
// Server-side pseudocode for allowing multiple origins
const allowedOrigins = ['https://mysite.com', 'https://app.mysite.com'];
const requestOrigin = request.headers['origin'];
if (allowedOrigins.includes(requestOrigin)) {
response.setHeader('Access-Control-Allow-Origin', requestOrigin);
response.setHeader('Vary', 'Origin'); // Important for caching!
}
When the server dynamically sets Access-Control-Allow-Origin based on the request's Origin, it must also include Vary: Origin in the response. This tells caches (CDNs, browser cache) that the response varies depending on the Origin header, preventing a cached response for one origin from being served to another.
Access-Control-Allow-Methods
Sent in preflight responses. Lists the HTTP methods the server allows:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
If the actual request's method is not in this list, the preflight fails and the request is blocked.
Access-Control-Allow-Headers
Sent in preflight responses. Lists the custom headers the server allows:
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
If the actual request includes a header not in this list (and not a CORS-safelisted header), the preflight fails.
Access-Control-Expose-Headers
By default, JavaScript can only access a small set of "safe" response headers from a cross-origin response:
Cache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
To expose additional headers, the server must list them:
Access-Control-Expose-Headers: X-Request-Id, X-Rate-Limit-Remaining, X-Total-Count
// Without Access-Control-Expose-Headers, custom headers are invisible
const response = await fetch('https://api.example.com/users');
// These safe headers are always accessible
console.log(response.headers.get('Content-Type')); // works
// Custom headers are null unless explicitly exposed by the server
console.log(response.headers.get('X-Total-Count')); // null (unless exposed)
console.log(response.headers.get('X-Request-Id')); // null (unless exposed)
Access-Control-Max-Age
Tells the browser how long (in seconds) to cache the preflight response. This prevents the browser from sending a preflight OPTIONS request before every single request:
Access-Control-Max-Age: 86400
This means the browser can reuse the preflight result for 24 hours (86400 seconds) before sending another OPTIONS request for the same URL pattern.
Without Max-Age:
OPTIONS /api/data → 204 (preflight)
PUT /api/data → 200 (actual)
OPTIONS /api/data → 204 (preflight again!)
PUT /api/data → 200 (actual)
With Max-Age: 86400:
OPTIONS /api/data → 204 (preflight, cached for 24h)
PUT /api/data → 200 (actual)
PUT /api/data → 200 (actual, no preflight needed)
PUT /api/data → 200 (actual, still cached)
Different browsers have different maximum values they will respect:
- Chrome: 7200 seconds (2 hours) maximum
- Firefox: 86400 seconds (24 hours) maximum