Every web developer hits this wall. You build a frontend, make a fetch call to your API, and get this in the 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.
You test the same URL in Postman. It works. You curl it from the terminal. It works. You open it directly in a browser tab. It works. But your JavaScript fetch call fails. The API is clearly functional -- so what is blocking you?
The answer is that CORS is not an API problem. It is a browser security mechanism. Postman, curl, and direct browser navigation do not enforce it. Only JavaScript running in a web page does.
What CORS actually is
CORS (Cross-Origin Resource Sharing) is a protocol that allows a server to indicate which origins (domain + protocol + port) are permitted to make requests to it from a browser context.
The "same-origin policy" is the default browser behavior: JavaScript on https://mysite.com can only make requests to https://mysite.com. Any request to a different origin (https://api.example.com, http://mysite.com -- different protocol, or https://mysite.com:8080 -- different port) is a cross-origin request and is blocked by default.
CORS is the opt-in mechanism that allows servers to relax this restriction. The server adds specific HTTP headers that tell the browser "yes, I permit requests from this origin."
How the protocol works
There are two types of CORS requests: simple requests and preflight requests.
Simple requests meet all of these conditions:
- Method is GET, HEAD, or POST
- Only "simple" headers are used (Accept, Accept-Language, Content-Language, Content-Type)
- Content-Type is one of: application/x-www-form-urlencoded, multipart/form-data, or text/plain
- No ReadableStream is used in the request
- No event listeners are registered on XMLHttpRequest.upload
For a simple request, the browser sends the request directly with an Origin header:
GET /data HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
The server responds, and the browser checks the response headers:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/json
{"status": "ok"}
If Access-Control-Allow-Origin matches the requesting origin (or is *), the browser allows JavaScript to access the response. If the header is missing or does not match, the browser blocks access. The response was received -- the server processed the request -- but JavaScript cannot read it.
Preflight requests are triggered when the request is not "simple." This includes:
- Methods other than GET, HEAD, POST (PUT, DELETE, PATCH)
- Custom headers (Authorization, X-API-Key, etc.)
- Content-Type of application/json
- Any request that does not qualify as "simple"
Before the actual request, the browser sends an OPTIONS request to ask for permission:
OPTIONS /data HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
The server must respond to this OPTIONS request with the allowed methods and headers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Only after the browser validates this preflight response does it send the actual request. Access-Control-Max-Age tells the browser to cache the preflight result (in seconds), so subsequent requests to the same endpoint skip the preflight.
The headers you need to know
Response headers (set by the server):
Access-Control-Allow-Origin: https://mysite.com
(or * for any origin -- but see caveats below)
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
Access-Control-Allow-Credentials: true
(allows cookies and auth headers to be sent)
Access-Control-Expose-Headers: X-Total-Count, X-Page-Size
(makes custom response headers readable by JavaScript)
Access-Control-Max-Age: 86400
(cache preflight for 24 hours)
The wildcard trap. Access-Control-Allow-Origin: * allows any origin, but it has a critical restriction: it cannot be used with Access-Control-Allow-Credentials: true. If your API uses cookies or the Authorization header, you must echo back the specific requesting origin, not the wildcard.
// Common server-side pattern: echo the Origin header
app.use((req, res, next) => {
const allowedOrigins = [
'https://mysite.com',
'http://localhost:3000'
];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
next();
});
Common errors and how to fix them
"No 'Access-Control-Allow-Origin' header is present." The server is not sending the header at all. Either the server-side CORS middleware is not configured, or it is configured for a different origin. Fix: add the header on the server.
"The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'." You are using fetch(url, { credentials: 'include' }) or the request sends cookies, and the server responds with *. Fix: respond with the specific origin instead of *.
"Method PUT is not allowed by Access-Control-Allow-Methods." The preflight response does not include the method you are using. Fix: add the method to Access-Control-Allow-Methods.
"Request header field authorization is not allowed by Access-Control-Allow-Headers." The preflight response does not include the Authorization header. Fix: add it to Access-Control-Allow-Headers.
Preflight 404 or 405. Your server framework does not handle OPTIONS requests for the endpoint. Many routing frameworks only register handlers for the methods you define (GET, POST, etc.) and return 404 or 405 for OPTIONS. Fix: add an OPTIONS handler or use CORS middleware that handles it automatically.
// Express.js with the cors package
const cors = require('cors');
app.use(cors({
origin: ['https://mysite.com', 'http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400
}));
CORS errors that are actually network errors. If the server is unreachable (DNS failure, firewall, server down), the browser may report it as a CORS error because there is no response and therefore no CORS headers. Check the Network tab for the actual HTTP status. If the request did not complete, it is not a CORS issue.
Common mistakes
Adding CORS headers on the client side. CORS headers are response headers set by the server. Adding Access-Control-Allow-Origin to your fetch request does nothing -- it is not a request header. The server must send it.
Using a CORS proxy in production. Services like cors-anywhere relay your request through a server that adds CORS headers. This works for development but introduces a single point of failure, adds latency, and sends your API credentials through a third-party server. Use them only for local development.
Disabling CORS in the browser. Chrome can be launched with --disable-web-security to ignore CORS. This is useful for debugging, but deploying this as a solution is a security disaster. CORS exists to protect your users.
Confusing CORS with authentication. CORS determines whether the browser allows the JavaScript to read the response. It does not replace API authentication. A public API with Access-Control-Allow-Origin: * still needs authentication if the data is sensitive.
Not handling the OPTIONS method on serverless platforms. AWS Lambda, Cloudflare Workers, and similar platforms often require explicit handling of OPTIONS requests. If your API works for GET but fails for POST with a preflight, check that your function handles the OPTIONS method.
When I am debugging CORS issues -- checking which headers a server actually returns, testing whether a preflight succeeds, or verifying my configuration before deploying -- I use the CORS tester at zovo.one/free-tools/cors-tester to see the raw preflight exchange.
CORS is not an obstacle. It is a security boundary that the browser enforces on your behalf. The fix is always on the server side: tell the browser which origins you trust. Once you internalize that, CORS errors become a five-minute configuration task instead of an afternoon of frustration.
I'm Michael Lip. I build free developer tools at zovo.one. 350+ tools, all private, all free.
Top comments (0)