CORS Errors: Every Fix That Actually Works
TL;DR
CORS errors are never quite the same. Here's every fix I've used in production, why each error happens, and actual code you can copy-paste for your specific situation.
If you're reading this, you're probably staring at:
Access to XMLHttpRequest at 'https://api.example.com' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
I've seen this error hundreds of times. I've also fixed it hundreds of different ways because CORS errors are never quite the same. The same code that works perfectly in production dies on localhost. The API that worked yesterday suddenly throws CORS errors today. And don't even get me started on cookies.
After years of wrestling with CORS in production, here's every fix that actually works, when to use each one, and why your specific error is happening.
Why This Error Is Lying to You
The first thing to understand about CORS errors is that they're often misleading. The error message says "No 'Access-Control-Allow-Origin' header is present" but that might not be the real problem.
I once spent three hours debugging a CORS error only to discover the API was returning a 500 error. The browser shows a CORS error because it can't read error responses from cross-origin requests. The actual problem had nothing to do with CORS.
Here's how to see what's really happening:
// What you see in the console: CORS error
// What's actually happening: check the Network tab
// The request might be failing for other reasons:
// - API is down (500, 502, 503 errors)
// - Wrong URL (404)
// - Authentication failed (401)
// - Method not allowed (405)
// Quick test: can you curl the endpoint?
// curl -X GET https://api.example.com/data
// If this fails, CORS isn't your problem
Chrome's error messages are different from Firefox's, which are different from Safari's. Firefox usually gives more helpful errors, so I often switch browsers just for debugging.
The Localhost Special Hell
Half my CORS problems happen in development. Here's why localhost is special:
// These are all different origins to the browser:
http://localhost:3000
http://localhost:3001 // Different port = different origin
http://127.0.0.1:3000 // Different host = different origin
http://[::1]:3000 // IPv6 != IPv4
https://localhost:3000 // Different protocol = different origin
// This is why your production code breaks locally
fetch('https://api.production.com/data') // Works in prod, fails locally
I can't count how many times I've "fixed" CORS by just using the right localhost URL.
Quick Fixes for Development (That Won't Bite You Later)
The Proxy Approach (What I Actually Use)
Instead of disabling security or using browser extensions, I proxy requests through my dev server:
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'https://api.production.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
// Now in your code:
fetch('/api/users') // Goes through proxy, no CORS
// create-react-app: setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'https://api.production.com',
changeOrigin: true,
})
);
};
This approach means your development environment mirrors production - no surprise CORS issues when you deploy.
Browser Extensions: Use With Caution
I used to recommend CORS browser extensions until one corrupted all my API responses by injecting headers incorrectly. If you must use them:
- Only enable for specific sites
- Disable immediately after debugging
- Never leave them on during actual development
The Real Fixes, By Scenario
Scenario 1: You Control the API
This should be easy, but I see so many people mess it up:
// The WRONG way (I see this everywhere)
app.use(cors()); // This allows EVERYTHING - massive security hole
// Also wrong
app.use(cors({
origin: '*' // Same as above, just explicit about it
}));
// What you actually want
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
'https://yourapp.com',
'https://www.yourapp.com',
process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);
// Allow requests with no origin (mobile apps, Postman, etc)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true // Only if you need cookies/auth headers
};
app.use(cors(corsOptions));
But here's what I actually do in production:
// I maintain an explicit allowlist
const ALLOWED_ORIGINS = {
production: ['https://app.example.com', 'https://www.example.com'],
staging: ['https://staging.example.com'],
development: ['http://localhost:3000', 'http://localhost:3001']
};
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowed = ALLOWED_ORIGINS[process.env.NODE_ENV] || [];
if (allowed.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') {
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,PATCH');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return res.sendStatus(200);
}
next();
});
Scenario 2: Third-Party API (The Proxy Pattern)
You can't make browsers ignore CORS, but you can avoid it entirely:
// This will NEVER work from the browser
async function fetchGitHubApi() {
// GitHub API doesn't allow browser requests from your domain
const response = await fetch('https://api.github.com/users/octocat');
return response.json();
}
// Instead, create your own endpoint
app.get('/api/github/users/:username', async (req, res) => {
const response = await fetch(
`https://api.github.com/users/${req.params.username}`,
{
headers: {
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
'User-Agent': 'My-App' // Many APIs require this
}
}
);
const data = await response.json();
res.json(data);
});
// Frontend now calls your API
fetch('/api/github/users/octocat') // No CORS issues
This is also more secure - your API keys stay on the server, not exposed in browser code.
Scenario 3: Cookies and Authentication (The Painful One)
This is where I've lost the most hours. When you include credentials, everything changes:
// Frontend code that breaks
fetch('https://api.example.com/user', {
credentials: 'include' // Send cookies
});
// Why it breaks:
// 1. Can't use wildcard origin
// 2. Need explicit Allow-Credentials header
// 3. Preflight requests for everything
Here's what actually works:
// Server must be explicit
app.use((req, res, next) => {
const origin = req.headers.origin;
// Cannot use * with credentials
if (isAllowedOrigin(origin)) {
res.header('Access-Control-Allow-Origin', origin); // Specific origin
res.header('Access-Control-Allow-Credentials', 'true');
// Cookies need additional settings
res.header('Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Requested-With');
}
next();
});
// Cookie configuration that works cross-origin
app.use(session({
cookie: {
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
secure: process.env.NODE_ENV === 'production', // HTTPS required
httpOnly: true
}
}));
Important: SameSite=None requires Secure, which requires HTTPS. This is why auth cookies often work in production but not in development.
Preflight Requests: The Hidden Performance Killer
I once had an API that was "slow" - turned out every request was actually two requests because of preflight:
// This triggers a preflight
fetch('https://api.example.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'value' // Custom header = preflight
},
body: JSON.stringify(data)
});
// Browser first sends:
// OPTIONS /api/endpoint HTTP/1.1
// Origin: http://localhost:3000
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: content-type, x-custom-header
Cache those preflight responses:
app.use((req, res, next) => {
if (req.method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '86400'); // Cache for 24 hours
res.sendStatus(204);
} else {
next();
}
});
Or avoid preflight entirely by sticking to "simple requests":
- Only GET, HEAD, or POST
- Only these headers: Accept, Accept-Language, Content-Language, Content-Type
- Content-Type only: application/x-www-form-urlencoded, multipart/form-data, or text/plain
Real-World Server Configurations
Nginx (What I Use in Production)
server {
location /api {
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
# Add CORS headers to actual requests
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_pass http://backend;
}
}
AWS Lambda (Serverless)
exports.handler = async (event) => {
// Lambda doesn't handle OPTIONS automatically
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': event.headers.origin || '*',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS'
},
body: ''
};
}
// Your actual handler
const result = await handleRequest(event);
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': event.headers.origin || '*',
'Access-Control-Allow-Credentials': 'true'
},
body: JSON.stringify(result)
};
};
The Debugging Checklist I Actually Use
When I hit a CORS error, I run through this:
Check the Network tab - Is it actually a CORS error or is the API returning an error?
Look for the OPTIONS request - If it's failing, your preflight handling is broken
Check response headers, not request headers - The browser sends
Origin, the server should respond withAccess-Control-Allow-OriginVerify the origin exactly -
http://localhost:3000!==http://localhost:3000/(trailing slash matters)Test with curl - Eliminate the browser entirely:
curl -H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: X-Requested-With" \
-X OPTIONS \
-v \
https://api.example.com/endpoint
- Check for credentials - If you're sending cookies, everything changes
Framework-Specific Quick Fixes
Next.js API Routes
// pages/api/example.js or app/api/example/route.js
export default function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
// Your API logic
}
Express with TypeScript
import cors from 'cors';
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
// Your logic
callback(null, true);
},
credentials: true
};
app.use(cors(corsOptions));
FastAPI (Python)
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=["*"],
allow_headers=["*"],
)
Security: What CORS Actually Protects
Here's what took me too long to understand: CORS doesn't protect your API. It protects users from malicious websites.
// CORS doesn't prevent this:
curl -X POST https://your-api.com/delete-everything
// It prevents this:
// Evil website can't make your browser send authenticated requests
fetch('https://your-bank.com/transfer', {
credentials: 'include',
method: 'POST',
body: JSON.stringify({ to: 'attacker', amount: 1000000 })
});
This is why Access-Control-Allow-Origin: * is usually fine for public, read-only APIs. It's not a security vulnerability if the data is meant to be public anyway.
My Current Approach
After years of CORS battles, here's what I do:
- Development: Use proxy configuration, avoid CORS entirely
- Public APIs: Allow all origins, no credentials
- Private APIs: Explicit allowlist, require authentication
- Third-party APIs: Always proxy through my backend
- Production: Configure CORS at the reverse proxy level (Nginx/Cloudflare)
The key insight: CORS errors are usually a symptom of architectural decisions. If you're fighting CORS constantly, you might need to rethink your architecture.
Sometimes the best CORS configuration is avoiding CORS entirely.