CORS: Why It Exists and How to Stop Fighting It
At some point you make an API call from your frontend, and instead of data you get: Access to fetch at 'https://api.example.com' from origin 'http://localhost:3000' has been blocked by CORS policy.
The usual response is to search for the error, find a Stack Overflow answer, add a header or a proxy setting, and move on. This works but leaves you in a position where CORS is a mysterious force that sometimes blocks things and requires incantations to fix.
Understanding why CORS exists changes the relationship with it. The behavior stops being arbitrary and the fixes become obvious.
The Same-Origin Policy
Before CORS, there was the Same-Origin Policy - a security constraint built into browsers that prevents JavaScript on one origin from reading responses from a different origin.
An origin is the combination of protocol, host, and port: https://example.com:443 is a different origin from http://example.com:443 (different protocol) and from https://api.example.com:443 (different subdomain).
The reason for this restriction: without it, any website could read your bank’s response to a request made with your credentials.
Imagine you’re logged into bank.example.com. You visit evil.com. JavaScript on evil.com makes a fetch to bank.example.com/account/balance. Your browser sends your session cookie. The bank responds with your balance. Without Same-Origin Policy, evil.com can read that response and exfiltrate it.
The Same-Origin Policy prevents cross-origin reads. The browser makes the request but blocks the JavaScript from reading the response.
What CORS Is
CORS - Cross-Origin Resource Sharing - is a mechanism that lets servers explicitly opt in to cross-origin requests. It’s a relaxation of the Same-Origin Policy, controlled by the server, not the browser.
When a browser makes a cross-origin request, it checks the response headers for permission. If the server hasn’t granted permission, the browser blocks the JavaScript from reading the response.
The key header: Access-Control-Allow-Origin. If a server responds with Access-Control-Allow-Origin: *, it’s saying “any origin can read this response.” If it responds with Access-Control-Allow-Origin: https://yourapp.com, it’s saying “only that specific origin can read this.”
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://yourapp.com
Content-Type: application/json
{"data": "..."}
The CORS error you see in the browser is the browser enforcing that the server has not granted permission. It is not a bug. It is the security model working correctly.
Simple vs Preflighted Requests
Not all cross-origin requests are treated the same way.
Simple requests - GET, HEAD, POST with certain content types - are sent directly. The browser makes the request and checks the Access-Control-Allow-Origin header on the response. If it doesn’t match, the browser blocks the JavaScript from reading the response (but the request was already made).
Preflighted requests - anything with a non-simple method (PUT, DELETE, PATCH) or custom headers or JSON body - trigger a preflight. Before the actual request, the browser sends an OPTIONS request to check permission:
OPTIONS /api/users HTTP/1.1
Origin: https://yourapp.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
The server responds with what it allows:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Only if the preflight is accepted does the browser send the actual request. This is why you sometimes see two requests in the network tab for a single fetch call.
Access-Control-Max-Age tells the browser how long to cache the preflight result. Setting it to 86400 (24 hours) means subsequent requests from the same origin to the same endpoint don’t need to preflight again.
Fixing CORS on Your Server
The fix for CORS errors is almost always server-side: configure the server to send the right headers.
Express (Node.js):
const cors = require('cors');
// Allow all origins (fine for public APIs)
app.use(cors());
// Allow specific origin
app.use(cors({
origin: 'https://yourapp.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
maxAge: 86400
}));
// Dynamic origin validation
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://yourapp.com', 'https://staging.yourapp.com'];
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
}
}));
The credentials case. If you’re sending cookies or Authorization headers, there’s an extra step. The browser won’t include credentials in cross-origin requests unless both sides opt in:
// Frontend: must include credentials option
fetch('https://api.example.com/data', {
credentials: 'include'
});
// Server: must respond with specific origin (not *) and credentials header
app.use(cors({
origin: 'https://yourapp.com', // cannot be * when credentials: true
credentials: true
}));
Access-Control-Allow-Origin: * with credentials is explicitly disallowed by the spec - a wildcard origin with credentials would defeat the purpose of the restriction.
Development: The Proxy Approach
In development, the most common setup is a frontend on localhost:3000 and an API on localhost:8080 - different ports, different origins, CORS applies.
Rather than configuring CORS headers for development, you can proxy API requests through the frontend dev server:
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
}
Now fetch('/api/users') goes to the Vite dev server, which forwards it to localhost:8080. Same origin as far as the browser is concerned, no CORS.
This is also a good production pattern: routing API traffic through the same origin as the frontend (via a reverse proxy like nginx) eliminates CORS entirely for same-origin setups.
Common Mistakes
Setting Access-Control-Allow-Origin: * in production on authenticated endpoints. The wildcard tells every origin it can read the response. For public read-only APIs, fine. For anything that serves user data, wrong. Use specific origins.
Handling CORS in the wrong place. CORS headers must be on the actual API response, not added by a CDN or load balancer without the API’s knowledge. If your OPTIONS preflight goes to a different server than your actual request, it won’t work.
Forgetting to handle OPTIONS. If your server doesn’t have a handler for OPTIONS requests, preflighted requests will get a 404 or 405 and fail. Most CORS middleware handles this automatically, but custom implementations need to explicitly handle OPTIONS.
Treating CORS as a backend problem when the frontend is wrong. If the frontend is making requests with custom headers it doesn’t need, the browser will preflight requests that could have been simple. Sometimes the fix is removing an unnecessary header from the request.
The One Thing to Remember
CORS is a browser-enforced security mechanism. It protects users from malicious sites reading their data from other sites using their credentials. When you see a CORS error, the server hasn’t told the browser it’s allowed to share that response with your origin. The fix is configuring the server to send the right headers - not finding a way to disable the browser’s enforcement.