CORS Errors Explained: Why Your Fetch Call Fails and How to Fix It
If you’ve spent any time building decoupled React/Vue frontends or integrating third-party APIs, you’ve hit the most despised red text in the Chrome console:
Access to fetch at 'https://api.example.com/v1/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
The common instinct is to Google “how to disable CORS,” paste a wildcard * header into the backend, or install a browser extension that strips CORS headers. These “fixes” work on your local machine and then create real security holes the moment they ship to production.
This guide explains how Cross-Origin Resource Sharing (CORS) works. By the end, you’ll stop fighting the browser and know how to set up secure cross-origin communication.
Dealing with large JSON responses after fixing CORS? Use our local JSON Formatter to validate, beautify, and inspect API payloads without sending data to a third-party server.
1. The Foundation: The Same-Origin Policy (SOP)
To understand CORS, you first need the policy it relaxes: the Same-Origin Policy (SOP).
In the early web, browser engineers spotted a serious vulnerability. If you were logged into your bank in Tab A (https://secure-bank.com) and then visited a malicious site in Tab B (https://evil-hacker.com), JavaScript on evil-hacker.com could fire a hidden POST to secure-bank.com/api/transfer-funds.
Because the browser automatically attaches your session cookies to any request to secure-bank.com, the bank would treat the request as legitimate and the transfer would succeed.
To stop this (a Cross-Site Request Forgery, or CSRF, attack), browsers introduced the Same-Origin Policy. The rule: an app on Origin A can only read responses from an API on the same Origin A.
What defines an origin: An origin is the exact combination of three things:
- Protocol: (
http://vshttps://) - Domain: (
example.comvsapi.example.com) - Port: (
:80vs:443vs:3000)
If any of the three differs between your frontend URL and your backend API URL, it’s a cross-origin request, and the browser blocks the JavaScript from reading the response by default.
2. The CORS Exemption
As the web moved to decoupled architectures (a React frontend on Vercel at https://app.com talking to a Go service on AWS at https://api.app.com), the Same-Origin Policy became a roadblock.
CORS is the official, secure exemption to the SOP. It’s a header-based negotiation between the browser and the server.
- Browser: “Server at
api.app.com, my user is onhttps://app.com. Are they allowed to read your data?” - Server: “Yes, I trust
https://app.com. Here’s the data, plus anAccess-Control-Allow-Origin: https://app.comheader proving it.” - Browser: “The headers match. I’ll let the frontend JavaScript read the response.”
The key mental model: CORS does not stop the request from reaching the server. If you send a DELETE to remove a user, the server receives it and runs the deletion. CORS only stops the frontend JavaScript from reading the response. It protects the user’s data from being stolen; it doesn’t protect your backend from traffic.
3. The Preflight: the OPTIONS Request
For “simple requests” (like a harmless GET for an image), the browser sends the request and checks the CORS headers afterward. For “complex requests” that could change backend state, the browser is more cautious — it sends a preflight request first.
sequenceDiagram
participant JS as Frontend (localhost:3000)
participant B as Browser Network Layer
participant S as Backend API (api.com)
JS->>B: fetch('api.com', { method: 'PUT', headers: {'Content-Type': 'application/json'} })
Note over B,S: The Mandatory Preflight Phase
B->>S: OPTIONS /data (Origin: localhost:3000, Access-Control-Request-Method: PUT)
S-->>B: 204 No Content (Access-Control-Allow-Methods: PUT)
Note over B,S: The Actual Data Request
B->>S: PUT /data
S-->>B: 200 OK (Access-Control-Allow-Origin: http://localhost:3000)
B-->>JS: Yields JSON Response to Application
What triggers a preflight?
- Any HTTP method other than
GET,POST, orHEAD(e.g.,PUT,DELETE,PATCH). - Any
Content-Typeother thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain(note: sendingapplication/jsontriggers a preflight). - Adding any custom header (e.g.,
Authorization: Bearer xyzorX-Api-Key: 123).
If your server doesn’t answer the OPTIONS request with a 200 or 204 plus the correct CORS headers, the browser kills the transaction and your actual PUT never fires.
4. The Five Most Common CORS Errors and Fixes
Error #1: Missing the Origin header
The console error:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Cause: your backend framework (Express, Spring Boot, Django) isn’t adding the required headers to its responses.
The fix (Node.js / Express):
Don’t write custom middleware — use the tested cors package.
const express = require('express');
const cors = require('cors');
const app = express();
// 1. Define a whitelist
const whitelist = ['https://my-production-app.com', 'http://localhost:3000'];
const corsOptions = {
origin: function (origin, callback) {
// 2. Allow requests with no origin (mobile apps, Postman, curl)
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS.'));
}
}
}
// 3. Apply the middleware globally
app.use(cors(corsOptions));
Error #2: Preflight failure (method not allowed)
The console error:
Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.
Cause: the browser sent an OPTIONS request to /api/v1/users, but your router returned 404 or 405 because you only defined an app.post('/api/v1/users') route. The framework doesn’t know how to handle the OPTIONS verb.
The fix (Python FastAPI):
Modern frameworks handle OPTIONS routing automatically when the CORS middleware is configured globally.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], # Allowed HTTP verbs
allow_headers=["*"], # Allow all custom headers
)
Error #3: The wildcard + credentials paradox
The console error:
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*'
when the request's credentials mode is 'include'.
Cause: your frontend is sending an HTTP-Only cookie (a session token) with fetch(url, { credentials: 'include' }), but your backend is responding with Access-Control-Allow-Origin: *.
The browser treats this as an unacceptable risk. You can’t say, “I accept authenticated cookies from any website on the internet.”
The fix (Go): Read the request’s origin, validate it against your whitelist, echo the exact origin back, and set the credentials flag.
func enableCors(w *http.ResponseWriter, r *http.Request) {
// 1. Read the incoming Origin header
origin := r.Header.Get("Origin")
// 2. Validate it against your whitelist (omitted for brevity)
// 3. Echo the exact origin back instead of '*'
(*w).Header().Set("Access-Control-Allow-Origin", origin)
// 4. Allow cookie credentials
(*w).Header().Set("Access-Control-Allow-Credentials", "true")
}
Error #4: Rejected custom headers
The console error:
Request header field 'X-Correlation-ID' is not allowed by
Access-Control-Allow-Headers in preflight response.
Cause: your frontend is attaching a custom header (X-Correlation-ID) or an Authorization: Bearer header. The backend has to authorize those headers during the OPTIONS preflight.
The fix (Nginx reverse proxy): If you handle CORS at the proxy layer (recommended for microservices), list the allowed headers.
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://frontend.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
# List the custom headers your services expect
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Correlation-ID' always;
# Cache the preflight response for 24 hours to cut latency
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain charset=UTF-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
}
Error #5: The exposed-headers trap
The bug: your fetch succeeds with 200 OK, but response.headers.get('X-Pagination-Total') returns null — even though you can see the header in the Network tab.
Cause: by default, browsers only let JavaScript read a small whitelist of “safe” response headers (like Content-Type). Custom response headers (pagination counts, rate-limit remaining) are hidden from JavaScript.
The fix:
The server has to expose the custom headers with Access-Control-Expose-Headers.
// Node.js Express
app.use(cors({
origin: 'http://localhost:3000',
// Un-hide these headers from JavaScript
exposedHeaders: ['X-Pagination-Total', 'X-RateLimit-Remaining']
}));
5. Bypassing CORS You Don’t Control
Sometimes you don’t own the third-party API and its CORS rules are misconfigured. You can’t fix that from the browser — you change your network architecture instead.
The reverse-proxy pattern
CORS is enforced only by the browser, so server-to-server communication is exempt.
Instead of calling the broken API directly from React, have React call your own backend. Your backend uses a server-side library (axios, node-fetch) to call the external API, parse the data, and forward it to React. This bypasses the browser’s CORS check entirely, and it has the bonus of keeping your private API keys out of the frontend source.
Further Reading
- HTTP Status Codes: The Complete Developer Guide
- cURL Command Guide: Debugging Network Requests
- XSS Prevention with HTML Entity Encoding
- DNS Records Explained: Architecture Troubleshooting
Don’t use browser extensions to disable security — set up your API correctly. To test cross-origin payloads, use our URL Encoder to format query parameters and the JSON Formatter to validate API responses locally.