Published on

CORS Explained, Why Your Request Is Being Blocked

Authors

You hit a public API from your frontend app. The network tab shows a successful response. But the browser blocks it, and the console says:

Access to XMLHttpRequest at 'https://api.example.com/users'
from origin 'http://localhost:3000' has been blocked by CORS policy

This is not an API error. The server responded. The browser blocked you from reading the response. Here's why.

The Same-Origin Policy

Browsers enforce the same-origin policy: JavaScript can only read responses from the same origin as the page it's running on.

An origin is the combination of scheme + host + port:

URLOrigin
https://example.com/api/usershttps://example.com
https://example.com:8080/apihttps://example.com:8080
http://example.com/apihttp://example.com
https://api.example.comhttps://api.example.com

Two URLs are same-origin only if all three parts match exactly. This is why localhost:3000 is a different origin from api.example.com.

The same-origin policy prevents a malicious page from making requests to https://yourbank.com and reading the response using your browser's cookies. It's a security feature, not a bureaucratic nuisance.

What CORS Is

Cross-Origin Resource Sharing (CORS) is a mechanism for servers to opt into cross-origin access. Without CORS, the same-origin policy blocks everything. With CORS, servers can say "requests from this other origin are allowed."

It works through HTTP headers.

Simple Requests

For requests that meet specific criteria (GET/HEAD/POST with certain content types), the browser just adds an Origin header to the request:

GET /api/users HTTP/1.1
Origin: http://localhost:3000

The server responds with an Access-Control-Allow-Origin header:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000

If the header matches the request's origin (or is * for public APIs), the browser allows JavaScript to read the response. If it's missing or doesn't match, the browser blocks it.

Note: the request went through. The server processed it and responded. CORS only controls whether the browser lets your JavaScript read the response.

Preflight Requests

For non-simple requests, DELETE, PUT, POST with JSON body, requests with custom headers, the browser sends a preflight OPTIONS request first:

OPTIONS /api/users/42 HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Content-Type, Authorization

The server responds with what it allows:

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: Content-Type, Authorization
Access-Control-Max-Age: 86400

Only if the preflight succeeds does the browser send the actual request. Access-Control-Max-Age tells the browser how long to cache the preflight result so it doesn't need to repeat it.

Credentials

By default, cross-origin requests don't include cookies or HTTP authentication. To include them, you need both sides to opt in:

Client:

fetch('https://api.example.com/users', {
  credentials: 'include', // send cookies with the request
})

Server:

Access-Control-Allow-Origin: http://localhost:3000  // must be specific, not *
Access-Control-Allow-Credentials: true

When credentials are involved, Access-Control-Allow-Origin: * is not allowed. The server must specify the exact origin.

Fixing CORS Issues

The fix is always on the server, not the client. Common approaches:

Development: use a proxy in your dev server to avoid cross-origin requests entirely:

// vite.config.ts
export default {
  server: {
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
}

Production: configure your server to send the right headers:

// Express
import cors from 'cors'

app.use(cors({
  origin: ['https://yourapp.com', 'https://staging.yourapp.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}))

Don't use origin: '*' in production for authenticated APIs, it bypasses the credential restriction and is a security risk.

The Mental Model

CORS is not a bug. It's the browser asking the server: "This page wants to read your response, do you consent?" The server answers with headers. The browser enforces the answer. Your job as a developer is to configure your server to consent to the right origins, and nothing else.