⏱️ 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
  1. TL;DR
  2. What CORS is and why it exists
  3. How CORS works in detail
  4. Practical examples
  5. Step-by-step walkthrough
  6. Real-world use cases
  7. Common mistakes and best practices
  8. Comparison with alternatives
  9. Going deeper: what happens under the hood
  10. Frequently Asked Questions
    1. Is CORS the same as a firewall?
    2. Why does my request work in Postman but fail in the browser?
    3. Can a user disable CORS in their own browser?
    4. Do I need to configure CORS if the frontend and the API are on the same domain?
    5. What’s the difference between the Origin and Referer headers?
  11. References

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.

CriterionSimple requestRequest with preflight
HTTP methodsGET, HEAD, POSTPUT, DELETE, PATCH, and others
Allowed Content-Typetext/plain, form-urlencoded, form-dataapplication/json and others
Custom headersNot allowedAuthorization, X-Api-Key, etc.
Actual requests to the server1 (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
Diagram of the CORS policy blocking a request between two different domains
The browser compares the script’s Origin against the server’s before handing over the response. Foto de Matias Luge en Unsplash

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.

ApproachWhen to use itAdvantageLimitation
CORSYour own API that you controlStandard, secure, granular per originRequires configuring the server
Reverse proxyThird-party API without CORSAvoids touching the external serverAdds infrastructure and latency
JSONPVery old legacy APIsWorks without fetch or CORSGET only, security risk, obsolete
postMessageCommunication between iframes or windowsDoesn’t go through HTTP, direct in the browserDoesn’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

📱 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


Andrés Morales

Developer and AI researcher. Writes about language models, frameworks, developer tooling, and open source releases. Covers ML papers, the tech startup ecosystem, and programming trends.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.