๐Ÿ“š Learning Hub
ยท 3 min read

How CORS Actually Works (And Why Your Request Gets Blocked)


Every web developer has seen this error:

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

CORS isnโ€™t a bug. Itโ€™s a security feature. But understanding why it exists and how it works makes it much less frustrating.

The Same-Origin Policy

Browsers enforce a rule: JavaScript on https://mysite.com can only make requests to https://mysite.com. Requests to any other origin (different domain, port, or protocol) are blocked by default.

This prevents a malicious site from making requests to your bankโ€™s API using your cookies. Without this rule, any website could silently access your authenticated sessions on other sites.

What CORS Actually Is

CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt in to receiving cross-origin requests. The server says โ€œIโ€™m okay with requests from these originsโ€ by setting response headers.

Access-Control-Allow-Origin: https://mysite.com

This header tells the browser: โ€œRequests from mysite.com are allowed.โ€ Without it, the browser blocks the response.

Important: the server still receives and processes the request. CORS is enforced by the browser, not the server. The server sends the response, but the browser refuses to give it to your JavaScript.

Simple Requests vs Preflight

Not all cross-origin requests are treated the same.

Simple requests go through directly. A request is โ€œsimpleโ€ if it uses GET, HEAD, or POST with standard headers and content types (text/plain, application/x-www-form-urlencoded, multipart/form-data).

Everything else triggers a preflight. If you send Content-Type: application/json, or use PUT/DELETE/PATCH, or add custom headers like Authorization, the browser sends an OPTIONS request first:

OPTIONS /api/users HTTP/1.1
Origin: https://mysite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server must respond with what it allows:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Only if the preflight passes does the browser send the actual request.

Why Preflight Exists

Preflight protects servers that were built before CORS existed. Old servers assumed that browsers would never send a DELETE request or a request with Content-Type: application/json from a different origin. Preflight ensures these servers arenโ€™t surprised by requests they never expected.

The Headers That Matter

# Which origins can access the resource
Access-Control-Allow-Origin: https://mysite.com
# Or allow any origin (careful with this):
Access-Control-Allow-Origin: *

# Which HTTP methods are allowed
Access-Control-Allow-Methods: GET, POST, PUT, DELETE

# Which request headers are allowed
Access-Control-Allow-Headers: Content-Type, Authorization

# Should the browser send cookies?
Access-Control-Allow-Credentials: true

# How long to cache the preflight response (seconds)
Access-Control-Max-Age: 86400

The Credentials Trap

If your request includes cookies or an Authorization header, you need Access-Control-Allow-Credentials: true. But hereโ€™s the catch: when credentials are included, Access-Control-Allow-Origin cannot be *. You must specify the exact origin.

# This won't work with credentials:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# This works:
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Credentials: true

This trips up almost everyone the first time.

Common Fixes

Development: Use a proxy. Your frontend dev server (Vite, Next.js) can proxy API requests so they appear same-origin:

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

Production: Set the headers on your API server. In Express:

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'https://mysite.com')
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
  next()
})

Nginx: Add headers at the reverse proxy level:

location /api/ {
    add_header Access-Control-Allow-Origin "https://mysite.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;

    if ($request_method = OPTIONS) {
        return 204;
    }
}

The Gotcha Nobody Mentions

CORS errors in the browser console donโ€™t show the actual server error. If your server returns a 500 error without CORS headers, the browser shows a CORS error โ€” not the 500. Always check your server logs when debugging CORS issues.

Related: CORS Error: Blocked by CORS Policy โ€” fix ยท How HTTPS Keeps Your Data Safe ยท What is CORS? ยท How Ssh Actually Works