⏱️ Lectura: 13 min
Your fetch works perfectly in Postman but the browser blocks it with a red error in the console: welcome to CORS, the policy that decides which origin is allowed to read your API’s response. It’s not a bug in your code or a network failure: it’s the browser enforcing a security rule that’s existed for over a decade.
📑 En este artículo
Understanding CORS thoroughly saves you hours of pointless debugging. In this guide you’ll see how the mechanism works under the hood, how to configure it correctly on a real server, and which mistakes come up most often when implementing it.
TL;DR
- You’ll understand what Origin is and why the browser compares it before delivering a response.
- You’ll be able to distinguish a simple request from one that triggers an OPTIONS preflight.
- You’ll configure Access-Control-Allow-Origin, Allow-Methods, Allow-Headers, and Allow-Credentials without errors.
- You’ll set up an Express server with the cors package and test it with curl step by step.
- You’ll avoid the classic mistake of combining wildcard * with credentials: true.
- You’ll verify with devtools whether the preflight was cached thanks to Access-Control-Max-Age.
- You’ll be able to tell CORS apart from a reverse proxy and postMessage for cases where you don’t control the backend.
What CORS is and why it exists
CORS stands for Cross-Origin Resource Sharing. It’s a browser mechanism, not something implemented by the server or your programming language, that controls whether a script loaded from one origin can read the response of a request made to a different origin.
An origin is defined by three parts: protocol, domain, and port. https://myapp.com and http://myapp.com are different origins because the protocol changes. https://myapp.com and https://api.myapp.com are also different because the subdomain changes. Even https://myapp.com:3000 is a different origin from https://myapp.com because of the port.
The reason this exists is the same-origin policy, a security rule present in every modern browser since the nineties. Without it, any website could include a script that silently calls your bank, your email, or your social network using the session cookies already stored in your browser, and read the response as if it were the site itself.
CORS doesn’t block the request itself: the server receives it and can process it normally. What gets blocked is the browser handing the response over to the JavaScript code that requested it, unless the server explicitly states, through HTTP headers, that this origin has permission to read it. This distinction is key and trips up a lot of beginners: the attack isn’t prevented by blocking the send, it’s prevented by blocking the reading of the response.
How CORS works in detail
When a script makes a cross-origin request, the browser automatically adds an Origin header with the domain the script is running from. The server receives that header and decides, using its own logic, whether to respond with Access-Control-Allow-Origin including that same origin (or a wildcard *).
Not all requests are treated the same way. The WHATWG Fetch standard splits cross-origin requests into two categories: simple requests and those that require a preflight.
| Criterion | Simple request | Request with preflight |
|---|---|---|
| HTTP methods | GET, HEAD, POST | PUT, DELETE, PATCH, and others |
| Allowed Content-Type | text/plain, form-urlencoded, form-data | application/json and others |
| Custom headers | Not allowed | Authorization, X-Api-Key, etc. |
| Actual requests to the server | 1 (the real request) | 2 (heads-up OPTIONS + the real one) |
For requests that don’t qualify as simple, the browser first sends an automatic OPTIONS request, called a preflight, before sending the real request. That request carries Access-Control-Request-Method and Access-Control-Request-Headers, announcing what the real request is about to attempt. The server has to respond with the Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers headers explicitly authorizing that.
sequenceDiagram
participant B as Browser
participant S as API Server
B->>S: OPTIONS /data (preflight)
Note over B,S: Origin, Access-Control-Request-Method
S-->>B: 204 with Access-Control-Allow-Origin
B->>S: GET /data (real request)
S-->>B: 200 with the data
Note over B,S: The browser hands the response to JS
If the server doesn’t respond with the correct headers, the browser never actually sends the real request (in the preflight case), or it sends the request but blocks JavaScript from reading the response (in the simple request case). Either way, the console shows a CORS error and your code gets a generic TypeError, not the response body or the real status code.
Practical examples
This is the most common case: a frontend on one domain calling an API on another without the server having CORS configured.
// Client on https://myapp.com trying to call another API
fetch('https://api.otherservice.com/users')
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error('Request failed:', err));
// Browser console:
// Access to fetch at 'https://api.otherservice.com/users' from origin
// 'https://myapp.com' has been blocked by CORS policy: No
// 'Access-Control-Allow-Origin' header is present on the requested resource.
The fetch runs and the server does receive the request: you can see it in the backend logs. But the browser never hands the JSON to .then(), and .catch() gets a generic network error with no detail about the status code.
The fix lives on the server side, not the client. Here’s a realistic example with Express and the cors package:
// server.js (Node.js + Express)
const express = require('express');
const cors = require('cors');
const app = express();
const corsOptions = {
origin: 'https://myapp.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
};
app.use(cors(corsOptions));
app.get('/users', (req, res) => {
res.json([{ id: 1, name: 'Ana' }]);
});
app.listen(3000, () => console.log('API on port 3000'));
With this configuration, the cors middleware checks the Origin header on every incoming request, and if it matches https://myapp.com it automatically adds Access-Control-Allow-Origin: https://myapp.com to the response. The browser sees that header and hands the JSON to the client’s fetch without blocking anything.
flowchart TD
A["Fetch request"] --> B{"Same origin?"}
B -->|"Yes"| C["Sent directly"]
B -->|"No"| D{"Is it a simple request?"}
D -->|"Yes"| E["Sent, browser checks the response"]
D -->|"No"| F["OPTIONS preflight first"]
F --> G{"Server authorizes?"}
G -->|"Yes"| H["Real request is sent"]
G -->|"No"| I["CORS error in console"]
Step-by-step walkthrough
To reproduce the example above on your machine, install the dependencies:
npm install express cors
Save the server code above as server.js and run it with node server.js. The API starts listening on port 3000.
To verify the CORS configuration responds correctly without relying on the browser, you can simulate the preflight with curl:
curl -i -X OPTIONS http://localhost:3000/users \n -H "Origin: https://myapp.com" \n -H "Access-Control-Request-Method: PUT" \n -H "Access-Control-Request-Headers: Content-Type, Authorization"
The response should include Access-Control-Allow-Origin: https://myapp.com, Access-Control-Allow-Methods with PUT in the list, and Access-Control-Allow-Headers with Content-Type and Authorization. If any of those three headers is missing, that’s exactly the point you need to fix in the server configuration.
From the browser, the way to confirm it is to open devtools, go to the Network tab, filter by the endpoint’s name, and check two entries: the OPTIONS request (the preflight) and the real request. If the preflight has status 204 or 200 with the correct headers, the real request should complete without a CORS error.
💡 Tip: Use Access-Control-Max-Age so the browser caches the preflight result and doesn’t repeat the OPTIONS request on every call during that time.
Real-world use cases
The most frequent scenario is a local development frontend, for example http://localhost:3001, talking to an API running on http://localhost:3000. Even though both are on the same machine, the different port already makes them different origins for the browser.
In production, it’s common to have the frontend served from a root domain and the API on a subdomain, like app.mycompany.com versus api.mycompany.com. There, CORS is mandatory because they’re different origins even though they share the main domain.
Another typical case is public APIs that serve third parties, like a weather API or an exchange rate API: they usually respond with Access-Control-Allow-Origin: * for any consumer, because they don’t handle session cookies or per-user sensitive data.
Web fonts (@font-face) also depend on CORS when served from a CDN different from the site’s own: that’s why the crossorigin attribute is sometimes needed on the tag that loads the font.
Common mistakes and best practices
The most repeated mistake is combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. The browser rejects that combination outright, no matter what the rest of the response says, because it would expose session cookies to any origin in the world.
⚠️ Watch out: If your request uses credentials: ‘include’ in the fetch, the server has to respond with an explicit origin in Access-Control-Allow-Origin, never with the wildcard *.
Another common mistake is forgetting that the server needs to explicitly handle the OPTIONS method. Many minimalist frameworks don’t do this by default, and if your route only defines a handler for GET or POST, the OPTIONS request hits a 404 and the preflight fails before ever reaching the real request.
It’s also common to confuse a CORS error with a generic network error. If the server is down or the URL is misspelled, the browser also shows a failure in the fetch, but without the specific “blocked by CORS policy” text in the console. Checking the exact message saves diagnosis time.
Finally, using mode: 'no-cors' on the client doesn’t solve the problem: it silences the error, but the response you get back is opaque (status 0, no readable body), so your code still can’t read the data.
Comparison with alternatives
CORS isn’t the only way to solve a cross-origin call, though it is the most standard option when you control the backend.
| Approach | When to use it | Advantage | Limitation |
|---|---|---|---|
| CORS | Your own API that you control | Standard, secure, granular per origin | Requires configuring the server |
| Reverse proxy | Third-party API without CORS | Avoids touching the external server | Adds infrastructure and latency |
| JSONP | Very old legacy APIs | Works without fetch or CORS | GET only, security risk, obsolete |
| postMessage | Communication between iframes or windows | Doesn’t go through HTTP, direct in the browser | Doesn’t work for calling a backend |
When you don’t control the third-party server and it doesn’t respond with CORS headers, the practical way out is usually your own reverse proxy: your backend calls the external API server-to-server (where CORS doesn’t apply, since it’s a browser-only restriction) and your frontend only talks to your proxy, which is on your own origin.
Going deeper: what happens under the hood
CORS isn’t an authentication mechanism and it doesn’t replace server-side authorization. An attacker can call your API directly with curl or Postman without ever going through a browser, so a misconfigured CORS setup isn’t the only layer of defense you need: tokens, validated sessions, and per-endpoint access control are still mandatory.
💭 Key point: CORS protects the browser user, it doesn’t protect your API. Server-side authentication and authorization remain mandatory with or without CORS.
There’s a direct relationship between CORS and CSRF (Cross-Site Request Forgery). Before CORS existed, browsers already allowed sending cross-origin forms without restriction, which enabled classic CSRF attacks. CORS restricts reading the response, but it doesn’t prevent a simple request, like a POST with form-urlencoded, from being sent anyway. That’s why anti-CSRF tokens are still necessary on sensitive endpoints that accept simple requests.
Another advanced level is cross-origin isolation headers, like Cross-Origin-Resource-Policy (CORP) and Cross-Origin-Embedder-Policy (COEP), which restrict what resources a page can embed even beyond what CORS covers, and which Chrome and Firefox require to enable sensitive APIs like SharedArrayBuffer.
There’s also Access-Control-Allow-Private-Network, a more recent extension designed for when a public page on the internet tries to call a service inside a private network or localhost, an increasingly common pattern with local development tools that expose APIs.
📖 Summary on Telegram: View summary
Your next step: spin up the Express server from this article, remove the app.use(cors(corsOptions)) line, and observe the exact error that shows up in the browser console when calling the endpoint from another origin.
Frequently Asked Questions
Is CORS the same as a firewall?
No. A firewall filters network traffic at the server or infrastructure level. CORS is a restriction applied exclusively by the end user’s browser; the server receives the request either way, only whether the browser shows the response to the script that requested it changes.
Why does my request work in Postman but fail in the browser?
Because Postman isn’t a browser and doesn’t apply the same-origin policy. CORS is a restriction of the browser environment, not of the HTTP protocol itself, so tools like Postman, curl, or a backend calling another backend never run into it.
Can a user disable CORS in their own browser?
Technically yes, with experimental flags or extensions, but that doesn’t fix anything for the rest of your users. CORS is configured on the server side because that’s where you have real control over who accesses the data.
Do I need to configure CORS if the frontend and the API are on the same domain?
No, if they share the exact same protocol, domain, and port, the browser treats them as the same origin and doesn’t apply any CORS restriction.
What’s the difference between the Origin and Referer headers?
Origin only indicates the requester’s scheme, domain, and port, with no path or query string, and it’s what CORS uses to make its decision. Referer includes the full origin URL and is used for other purposes, like analytics or additional CSRF protection, but it doesn’t play a role in the CORS decision.
References
- MDN: Cross-Origin Resource Sharing (CORS): a complete reference guide to the mechanism and its headers.
- WHATWG Fetch Standard: the specification that formally defines the CORS protocol.
- MDN: Access-Control-Allow-Origin: details on CORS’s central header.
- expressjs/cors on GitHub: source code of the middleware used in this article’s examples.
📱 Like this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day. @programacion
Imagen destacada: Foto de Valery Sysoev en Unsplash
0 Comments