If you've spent more than a week writing JavaScript, you've seen this in the browser console:
Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
CORS (Cross-Origin Resource Sharing) errors are among the most searched developer errors of any year. They're confusing because the request succeeds on the server — yet the browser silently discards the response.
This guide covers exactly what CORS is, why browsers enforce it, and every fix available to you.
What CORS Actually Is (and Isn't)
CORS is not a firewall. It is not a security measure on the server. It is a browser-enforced policy that prevents JavaScript running on one origin from reading responses from a different origin, unless the server explicitly says otherwise.
The key insight: the request still goes out. The server still processes it. The server still responds. The browser then checks the response headers — and if the right header isn't there, it discards the response and throws a CORS error into the console.
This means:
- CORS errors only happen in browsers.
curl, Postman, server-to-server calls — none of these are affected. - The server did receive your request. If it was a mutation (POST, PUT, DELETE), it may have already executed.
- The fix is almost always on the server, not the browser.
Same-Origin Policy
CORS exists because of the Same-Origin Policy (SOP), a foundational browser security rule. Two URLs are the same origin only if all three of these match exactly:
| Component | Example |
|---|---|
| Protocol | https:// vs http:// — different |
| Hostname | api.example.com vs example.com — different |
| Port | :3000 vs :8080 — different |
So https://api.example.com and https://example.com are different origins, even though they share the same registered domain.
Simple Requests vs. Preflighted Requests
Not all cross-origin requests are treated the same way.
Simple requests
A request is "simple" if it meets all of these:
- Method is
GET,POST, orHEAD - Headers are limited to
Accept,Accept-Language,Content-Language,Content-Type Content-Typeis one ofapplication/x-www-form-urlencoded,multipart/form-data,text/plain
Simple requests are sent immediately. The browser checks the response for Access-Control-Allow-Origin.
Preflighted requests
Any request outside those constraints triggers a preflight — an automatic OPTIONS request sent before the real one. The browser asks: "Server, will you accept a POST with Content-Type: application/json and an Authorization header?"
The server must respond correctly to the preflight before the browser sends the real request.
This is why adding a custom header like Authorization: Bearer <token> suddenly causes CORS failures even on endpoints that worked before — you crossed from a simple request into a preflighted one.
Fix 1: Set the Correct Response Headers (Server-Side)
The most correct fix is to configure your server to include CORS headers.
Express (Node.js)
import cors from 'cors';
import express from 'express';
const app = express();
// Allow all origins (development only — never use in production)
app.use(cors());
// Allowlist specific origins (production)
app.use(cors({
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // required if using cookies or Authorization headers
maxAge: 86400, // cache preflight for 24 hours
}));
Critical: If you use credentials: true, you cannot use Access-Control-Allow-Origin: *. You must specify an exact origin. Browsers reject the wildcard + credentials combination.
Manual headers (any framework)
Access-Control-Allow-Origin: https://yourdomain.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Handle the preflight:
app.options('*', cors()); // respond to all OPTIONS requests
Nginx
location /api/ {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
}
Fix 2: Use a Server-Side CORS Proxy
If you don't control the target API — third-party services, public datasets, legacy systems — you can't add headers to their responses. The solution is a proxy: your browser calls your own server, your server calls the external API, and your server forwards the response with CORS headers attached.
This is exactly how a CORS proxy works:
Browser → your-server.com/proxy?url=https://api.third-party.com/data
↓
your-server makes the fetch (no CORS restriction)
↓
your-server returns the response with Access-Control-Allow-Origin: *
↓
Browser receives it, CORS check passes ✓
A minimal Express proxy:
import express from 'express';
const app = express();
app.get('/proxy', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url param required' });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const upstream = await fetch(url, { signal: controller.signal });
const body = await upstream.text();
res.set({
'Access-Control-Allow-Origin': '*',
'Content-Type': upstream.headers.get('content-type') ?? 'text/plain',
}).status(upstream.status).send(body);
} catch (err) {
res.status(502).json({ error: String(err) });
} finally {
clearTimeout(timeout);
}
});
Security note: Always validate the url parameter on your proxy. Block requests to private IP ranges (10.x.x.x, 172.16–31.x.x, 192.168.x.x, 127.x.x.x), loopback hostnames (localhost), and internal domain suffixes. A CORS proxy that blindly forwards to any URL is an SSRF vulnerability. DeveloperLab's public CORS proxy blocks all private IPs and internal hostnames by default.
Fix 3: Vite / webpack Dev Proxy (Local Development Only)
During local development, you can route API calls through your dev server. This is transparent to your frontend code.
Vite (vite.config.ts)
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.third-party.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
});
Your frontend calls /api/data. Vite forwards it to https://api.third-party.com/data. No CORS error because the browser sees the same origin.
Create React App (package.json)
{
"proxy": "https://api.third-party.com"
}
This only works for simple one-target proxying and development builds.
Fix 4: JSONP (Legacy — Avoid)
JSONP pre-dates CORS and works by injecting a <script> tag rather than making an XHR/fetch call. Script tags are not subject to CORS. The server wraps the response in a function call: callback({"data": ...}).
Don't use JSONP. It only supports GET requests, has no error handling, and is a significant XSS vector. Modern APIs don't offer it. It's listed here only so you recognize it in older codebases.
Common Mistakes
Mistake 1: Adding CORS headers only to 200 responses
Preflight OPTIONS requests return 204 (or 200). If your middleware only adds headers to successful responses, preflights fail.
// ❌ Wrong — OPTIONS returns before any route handler with body
app.get('/data', corsMiddleware, handler);
// ✅ Correct — OPTIONS handled globally
app.options('*', cors());
app.use(cors());
Mistake 2: Returning * with credentials: true
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject this combination. If you're sending cookies or Authorization headers, you must return the specific requesting origin, not a wildcard.
Mistake 3: Proxy in code, headers missing in production
Local dev proxies (Vite, CRA) disappear in production builds. If you rely on a dev proxy without setting up the equivalent reverse proxy in production (Nginx, Cloudflare, etc.), you'll see CORS errors only in production.
Mistake 4: Multiple Access-Control-Allow-Origin headers
Some frameworks add the header, and then a middleware adds it again. Duplicate Access-Control-Allow-Origin headers cause browsers to reject the response. Audit your middleware chain.
Quick Reference
| Scenario | Fix |
|---|---|
| You control the API server | Add CORS headers server-side |
| Third-party API, no headers | Use a server-side proxy |
| Local development only | Vite/webpack dev proxy |
| Need to test quickly | Public CORS proxy (development only) |
| Using cookies/auth | Set exact origin, credentials: true |
| Preflight failing | Add OPTIONS handler, Access-Control-Max-Age |
Testing Your CORS Setup
Use curl to simulate a preflight and verify your headers are correct:
# Simulate a preflight
curl -X OPTIONS https://api.yourdomain.com/data \
-H "Origin: https://yourdomain.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type,Authorization" \
-I
# Expected response headers:
# Access-Control-Allow-Origin: https://yourdomain.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type, Authorization
# Access-Control-Max-Age: 86400
For quick testing against third-party APIs without touching your server, DeveloperLab's CORS Proxy tool lets you paste any public URL and fetch it through a server-side proxy — with full response headers, body, and timing visible in the browser.