Skip to main content

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): http or https
  • 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 AURL BSame Origin?Why?
https://site.com/page1https://site.com/page2YesSame protocol, host, port
https://site.comhttp://site.comNoDifferent protocol
https://site.comhttps://api.site.comNoDifferent host (subdomain)
https://site.comhttps://site.com:8080NoDifferent port
http://site.com:80http://site.comYesPort 80 is default for HTTP
https://site.com:443https://site.comYesPort 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.

note

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:

  1. Your JavaScript calls fetch('https://api.example.com/data') from https://mysite.com
  2. The browser adds an Origin: https://mysite.com header to the request
  3. The server receives the request and checks the Origin header
  4. If the server allows this origin, it includes Access-Control-Allow-Origin: https://mysite.com in the response
  5. 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
caution

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:

  • GET
  • HEAD
  • POST

Headers are limited to CORS-safelisted headers:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (with restrictions below)

Content-Type (if present) is one of:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/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 request
  • Access-Control-Request-Method: Which HTTP method the actual request will use
  • Access-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 Origin request 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!
}
note

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-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

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

Access-Control-Allow-Credentials

Controls whether cookies and other credentials are allowed in cross-origin requests. Covered in detail in the next section:

Access-Control-Allow-Credentials: true

Complete Header Reference

HeaderDirectionPresent InPurpose
OriginRequestAll cross-origin requestsIdentifies the requesting origin
Access-Control-Request-MethodRequestPreflight onlyMethod of the actual request
Access-Control-Request-HeadersRequestPreflight onlyHeaders of the actual request
Access-Control-Allow-OriginResponsePreflight + ActualAllowed origin(s)
Access-Control-Allow-MethodsResponsePreflight onlyAllowed HTTP methods
Access-Control-Allow-HeadersResponsePreflight onlyAllowed request headers
Access-Control-Expose-HeadersResponseActual onlyReadable response headers
Access-Control-Max-AgeResponsePreflight onlyPreflight cache duration
Access-Control-Allow-CredentialsResponsePreflight + ActualAllow cookies/credentials

Credentials in CORS Requests

By default, cross-origin fetch() requests do not include cookies, HTTP authentication, or TLS client certificates. This is a security measure: you do not want a random website automatically sending your session cookies to another domain.

To include credentials, both the client and server must explicitly opt in.

Client Side: credentials Option

The fetch() function has a credentials option with three values:

// "omit": Never send credentials (default for cross-origin)
fetch('https://api.example.com/data', {
credentials: 'omit'
});

// "same-origin": Send credentials only for same-origin requests (default for same-origin)
fetch('https://api.example.com/data', {
credentials: 'same-origin'
});

// "include": Always send credentials, even for cross-origin requests
fetch('https://api.example.com/data', {
credentials: 'include'
});

Only credentials: 'include' sends cookies in cross-origin requests:

// Login request that sends and receives cookies cross-origin
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
credentials: 'include', // Send cookies with this request
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: 'user@example.com',
password: 'secret'
})
});

// Subsequent requests also need credentials: 'include'
// to send the session cookie that was set during login
const profile = await fetch('https://api.example.com/profile', {
credentials: 'include' // Send the session cookie
});

Server Side: Credential Requirements

When the client sends credentials: 'include', the server must meet three strict requirements, or the browser blocks the response:

1. Access-Control-Allow-Credentials: true must be present:

Access-Control-Allow-Credentials: true

2. Access-Control-Allow-Origin must NOT be *:

The wildcard * is not allowed with credentials. The server must echo back the exact requesting origin:

# WRONG with credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# Browser blocks this!

# CORRECT with credentials
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Credentials: true

3. Access-Control-Allow-Headers must NOT be * (for preflight):

When credentials are involved, the wildcard * in Access-Control-Allow-Headers and Access-Control-Allow-Methods is treated as the literal string "*", not a wildcard. The server must list the actual allowed headers and methods:

# WRONG with credentials
Access-Control-Allow-Headers: *
Access-Control-Allow-Credentials: true

# CORRECT with credentials
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

Why These Restrictions Exist

The restrictions around credentials exist to prevent a dangerous attack:

  1. User logs into https://bank.com (session cookie is set)
  2. User visits https://evil-site.com
  3. evil-site.com runs: fetch('https://bank.com/api/accounts', { credentials: 'include' })
  4. If bank.com responds with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true, the evil site could read the user's bank data

The browser prevents this by requiring:

  • The server explicitly names the origin (no wildcards), proving it intentionally trusts this specific origin
  • The server explicitly allows credentials, proving it understands cookies are being sent

Complete Credentials Example

Client (https://mysite.com):

// API wrapper that always includes credentials
class ApiClient {
constructor(baseURL) {
this.baseURL = baseURL;
}

async request(endpoint, options = {}) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
...options,
credentials: 'include', // Always send cookies
headers: {
'Content-Type': 'application/json',
...options.headers
}
});

if (!response.ok) {
if (response.status === 401) {
// Redirect to login
window.location.href = '/login';
return;
}
throw new Error(`API error: ${response.status}`);
}

return response.json();
}

get(endpoint) {
return this.request(endpoint);
}

post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
}

const api = new ApiClient('https://api.example.com');

// Login (sets session cookie)
await api.post('/auth/login', { email: 'user@example.com', password: 'secret' });

// Authenticated request (sends session cookie)
const profile = await api.get('/me');
console.log(profile);

Server (https://api.example.com) pseudocode:

// Express.js example
const express = require('express');
const cors = require('cors');

const app = express();

app.use(cors({
origin: 'https://mysite.com', // Exact origin, not *
credentials: true, // Allow credentials
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Request-Id'],
maxAge: 86400
}));

Common CORS Scenarios and Solutions

Scenario 1: Public API (No Credentials)

For a public API that anyone can access:

Server response headers:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type

Client code:

// Simple, no credentials needed
const response = await fetch('https://public-api.example.com/data');
const data = await response.json();

Scenario 2: Authenticated API with Tokens

For APIs that use Authorization header with Bearer tokens (no cookies):

Server response headers:

Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: X-Total-Count
Access-Control-Max-Age: 3600

Client code:

// Token in header, no credentials option needed
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
'Content-Type': 'application/json'
}
});

// This triggers a preflight because of Authorization header
// Browser sends OPTIONS first, then the actual GET

For traditional session-based authentication with cookies:

Server response headers:

Access-Control-Allow-Origin: https://mysite.com   (NOT *)
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type

Client code:

const response = await fetch('https://api.example.com/data', {
credentials: 'include' // Required for cookies
});

Scenario 4: Development with Different Ports

During development, your frontend might run on localhost:3000 and your backend on localhost:8080. These are different origins (different ports).

Option A: Configure the server for CORS:

// Express.js backend on port 8080
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));

Option B: Use a development proxy (no CORS needed):

Most frontend tools (Vite, webpack, Create React App) support proxying API requests through the dev server, eliminating the cross-origin issue entirely:

// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
};
// Your fetch call uses a relative URL (same origin through the proxy)
const response = await fetch('/api/data');
// Vite dev server proxies this to http://localhost:8080/api/data
// No CORS involved!
tip

During development, using a proxy is often simpler than configuring CORS headers. The proxy makes the request appear to come from the same origin, so CORS is never triggered. However, you must still configure proper CORS headers on your production server, because in production your frontend and backend typically live on different origins.

Debugging CORS Errors

CORS errors can be frustrating because the browser intentionally hides details (for security reasons). Here is a systematic approach to debugging them.

Step 1: Read the Error Message Carefully

The console error message tells you exactly what went wrong:

// Missing Allow-Origin header
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.
→ Server needs to add Access-Control-Allow-Origin header

// Preflight failed
Access to fetch at 'https://api.example.com/data' from origin 'https://mysite.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: The value of the 'Access-Control-Allow-Origin' header must not be the
wildcard '*' when the request's credentials mode is 'include'.
→ Server uses * but client sends credentials: 'include'

// Method not allowed
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.
→ Server needs to add PUT to Access-Control-Allow-Methods

// Header not allowed
Request header field Authorization is not allowed by Access-Control-Allow-Headers
in preflight response.
→ Server needs to add Authorization to Access-Control-Allow-Headers

Step 2: Check the Network Tab

Open DevTools, go to the Network tab, and look for:

  1. Is there an OPTIONS request? If yes, check its response headers for the Access-Control-Allow-* headers
  2. What status code does the OPTIONS request return? It should be 200 or 204. A 404 or 405 means the server does not handle OPTIONS requests
  3. Are the correct CORS headers present? Check both the preflight response and the actual response

Step 3: Verify the Server Configuration

Common server issues:

// Problem: Server only sets CORS headers for specific routes, not for OPTIONS
// The preflight OPTIONS request gets a 404 or no CORS headers

// Problem: Server sets Access-Control-Allow-Origin but not on preflight responses
// Fix: CORS headers must be present on BOTH the OPTIONS response AND the actual response

// Problem: Server sets Access-Control-Allow-Origin: * with credentials
// Fix: Use the specific origin instead of *

Step 4: Common Quick Checks

// 1. Is the URL correct? (typos cause different CORS behavior)
fetch('https://api.example.com/data'); // correct
fetch('https://ap.example.com/data'); // different domain = different CORS

// 2. Is it actually cross-origin? Check protocol, host, AND port
fetch('http://localhost:3000/api'); // same origin if page is on localhost:3000
fetch('http://localhost:8080/api'); // cross-origin! Different port

// 3. Are you setting Content-Type: application/json?
// This triggers a preflight. Check that the server handles OPTIONS
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // Triggers preflight
body: JSON.stringify(data)
});

What You CANNOT Fix from JavaScript

A crucial point: CORS errors cannot be fixed in your frontend code. The fix is always on the server side. If you do not control the server, you have limited options:

  1. Ask the server admin to add CORS headers
  2. Use a proxy on your own server that makes the request server-to-server (CORS only applies to browser requests)
  3. Check if the API supports JSONP (an older cross-origin technique)
// A simple proxy approach (your own server)
// Instead of calling the third-party API directly from the browser:
// fetch('https://third-party-api.com/data') // CORS error

// You call your own backend:
// fetch('/api/proxy/data')
// Your backend then calls the third-party API server-to-server (no CORS)
caution

Never use public "CORS proxy" services (like cors-anywhere) in production. They route all your API traffic through a third party, exposing user data, authentication tokens, and creating a single point of failure. They are acceptable for quick development testing only.

Opaque Responses and no-cors Mode

You may have seen suggestions to use mode: 'no-cors' to "fix" CORS errors:

const response = await fetch('https://api.example.com/data', {
mode: 'no-cors'
});

This does suppress the CORS error, but the result is an opaque response that is essentially useless:

const response = await fetch('https://api.example.com/data', {
mode: 'no-cors'
});

console.log(response.type); // "opaque"
console.log(response.status); // 0
console.log(response.ok); // false

const text = await response.text();
console.log(text); // "" (empty string!)
// You cannot read any data from an opaque response

An opaque response has no readable body, no accessible headers, and a status of 0. It exists primarily for Service Workers that need to cache cross-origin resources (like CDN images) without reading their contents.

no-cors does not fix CORS. It silences the error and gives you an empty response. Do not use it when you need to read the response data.

The mode Option Values

For completeness, here are all the mode values for fetch():

// "cors": Default for cross-origin requests. CORS headers are required.
fetch(crossOriginUrl, { mode: 'cors' });

// "same-origin": Only allows same-origin requests. Cross-origin requests fail.
fetch(crossOriginUrl, { mode: 'same-origin' }); // TypeError

// "no-cors": Allows cross-origin requests but returns opaque response.
// Only simple requests are allowed (GET, HEAD, POST with limited headers).
fetch(crossOriginUrl, { mode: 'no-cors' });

// "navigate": Used by browsers for page navigation. Not for fetch().

Summary

The Same-Origin Policy prevents JavaScript on one origin from reading responses from another origin. This protects users from malicious websites stealing their data from other sites they are logged into.

CORS (Cross-Origin Resource Sharing) is the mechanism that allows servers to selectively relax the Same-Origin Policy. It is a server-side configuration that tells browsers which origins, methods, and headers are permitted. CORS errors cannot be fixed from frontend JavaScript; the server must send the correct Access-Control-Allow-* headers.

Requests are divided into simple and preflighted. Simple requests (GET/HEAD/POST with basic headers) go directly. Preflighted requests (PUT, DELETE, custom headers, Content-Type: application/json) trigger an automatic OPTIONS request first. The preflight asks the server for permission; the actual request follows only if permission is granted. The Access-Control-Max-Age header lets browsers cache preflight results to avoid repeated OPTIONS requests.

For credentials (cookies, HTTP auth), both sides must opt in: the client sets credentials: 'include' on the fetch call, and the server responds with Access-Control-Allow-Credentials: true and a specific origin (not *) in Access-Control-Allow-Origin.

When debugging CORS, read the console error carefully (it tells you exactly what is missing), check the Network tab for the OPTIONS preflight and its response headers, and remember that the fix is always on the server. Never use mode: 'no-cors' as a workaround when you need to read response data, because opaque responses have no readable content.