CORS

intermediate cors security browser same-origin-policy

At some point, every web developer has seen this error in the browser 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.

It’s frustrating the first time, but once we understand why it exists and how it works, CORS becomes straightforward.

Same-Origin Policy

The browser has a security mechanism called the Same-Origin Policy. It says: “JavaScript on page A can only make requests to page A’s origin.” An origin is the combination of protocol + domain + port.

https://example.com:443  ← this is one origin
  ↓        ↓        ↓
protocol  domain    port

These are considered different origins:

  • http://example.com and https://example.com — different protocol
  • https://example.com and https://api.example.com — different subdomain
  • http://localhost:3000 and http://localhost:8080 — different port

Why does the browser do this? Imagine we’re logged into our bank at bank.com. If a malicious page at evil.com could freely make requests to bank.com using our cookies, it could transfer our money. The Same-Origin Policy prevents this.

What CORS Actually Is

CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt in to allowing cross-origin requests. It’s not a security wall — it’s a door with a lock that the server controls.

When our frontend at localhost:3000 makes a request to our API at localhost:8080, the browser asks the server: “Hey, is localhost:3000 allowed to talk to you?” If the server says yes (through CORS headers), the browser allows it. If not, the browser blocks the response.

Important: CORS is enforced by the browser, not the server. The server sends the response either way. The browser just refuses to let our JavaScript see it if the CORS headers are missing. This is why the same request works fine from curl or Postman — they don’t enforce CORS.

Simple Requests vs Preflight Requests

Not all cross-origin requests are treated the same.

Simple requests go straight through. The browser sends the request, gets the response, and checks the CORS headers. A request is “simple” if:

  • Method is GET, HEAD, or POST
  • Only standard headers (Accept, Content-Type with text/plain, multipart/form-data, or application/x-www-form-urlencoded)
  • No custom headers

Preflight requests happen when the request doesn’t qualify as “simple.” Before sending the actual request, the browser sends an OPTIONS request first to ask for permission.

Browser
localhost:3000
API Server
api.example.com
1. OPTIONS /api/data ──────→ preflight: "Can I POST with JSON?"
2. 200 + CORS headers: "Yes, go ahead" ←────── 200 OK
3. POST /api/data ──────→ actual request with JSON body
4. response data + CORS headers ←────── 200 OK + Data
Steps 1-2 are the preflight. Steps 3-4 are the actual request.

Common triggers for preflight:

  • Using Content-Type: application/json (very common with APIs)
  • Sending custom headers like Authorization
  • Using methods like PUT, PATCH, or DELETE

CORS Response Headers

The server controls CORS through these response headers:

Access-Control-Allow-Origin — Which origins are allowed. Can be a specific origin or * (any origin).

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

Access-Control-Allow-Methods — Which HTTP methods are allowed for cross-origin requests.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE

Access-Control-Allow-Headers — Which custom headers the client is allowed to send.

Access-Control-Allow-Headers: Content-Type, Authorization

Access-Control-Allow-Credentials — Whether cookies and auth headers are allowed.

Access-Control-Allow-Credentials: true

Access-Control-Max-Age — How long (in seconds) the browser can cache the preflight response. Saves extra OPTIONS requests.

Access-Control-Max-Age: 86400

Fixing CORS in Development

In development, our frontend is usually on localhost:3000 and our API is on localhost:8080. Different ports = different origins = CORS error.

The easiest fix is to proxy the API through the dev server so the browser thinks everything is same-origin:

# Vite config (vite.config.ts)
# export default defineConfig({
#   server: {
#     proxy: {
#       '/api': {
#         target: 'http://localhost:8080',
#         changeOrigin: true,
#       }
#     }
#   }
# })

# Now fetch('/api/users') in the browser goes to:
# localhost:3000/api/users → proxied to → localhost:8080/api/users
# No CORS issue because the browser only sees localhost:3000

This proxy only exists in development. In production, we need proper server-side CORS headers.

Fixing CORS in Production

On the server side, we add the CORS headers to the response. Here’s how in a Node.js/Express app:

# Using the cors middleware (npm install cors)
# const cors = require('cors');
#
# // Allow a specific origin
# app.use(cors({
#   origin: 'https://myapp.com',
#   methods: ['GET', 'POST', 'PUT', 'DELETE'],
#   credentials: true,
# }));

Or in Nginx:

# nginx.conf
# location /api/ {
#     add_header Access-Control-Allow-Origin "https://myapp.com";
#     add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE";
#     add_header Access-Control-Allow-Headers "Content-Type, Authorization";
#
#     if ($request_method = 'OPTIONS') {
#         add_header Access-Control-Max-Age 86400;
#         return 204;
#     }
# }

The Credentials + Wildcard Gotcha

This trips up a lot of people. When we need to send cookies or auth headers with cross-origin requests, we set credentials: 'include' on the fetch call:

# fetch('https://api.example.com/data', {
#   credentials: 'include'  // send cookies cross-origin
# })

But here’s the catch: when credentials are included, the server CANNOT use Access-Control-Allow-Origin: *. It must specify the exact origin.

# THIS WILL FAIL when credentials are included:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# THIS WORKS:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

The browser enforces this strictly. The wildcard * with credentials is considered too permissive and is blocked. We must explicitly list the allowed origin.

Quick Debugging Checklist

When we hit a CORS error, here’s the mental checklist:

  1. Check the browser console — It tells us exactly which header is missing
  2. Check the Network tab — Look for the OPTIONS preflight request. Did it get a 200? Are the CORS headers present?
  3. Is the server sending CORS headers? — Use curl -I to check the response headers directly
  4. Using credentials? — Make sure the origin is explicit, not *
  5. Are the allowed methods/headers correct? — A missing Authorization in Allow-Headers will block JWT auth
# Check what CORS headers a server sends
curl -I -X OPTIONS https://api.example.com/data \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type"

In simple language, CORS is the browser asking the server “Is this frontend allowed to talk to you?” — and the server answers through response headers.